Commit c3b67f3a by David Kaltschmidt

Explore: Logging label filtering

- adds a custom label renderer to Logs viewer in Explore
- labels are no longer treated as strings, they are passed as parsed objects to the log row
- label renderer supports onClick handler for an action
- renamed Explore's `onClickTableCell` to `onClickLabel` and wired up log label renderers
- reuse Prometheus `addLabelToSelector` to modify Logging queries via click on label
- added tests to `addLabelToSelector`, changed to include the surrounding `{}`
- use label render also for common labels in the controls panel
- logging meta data section has now a custom renderer that can render numbers, strings, and labels
- style adjustments
parent 8830c133
......@@ -35,19 +35,26 @@ export interface LogRow {
duplicates?: number;
entry: string;
key: string; // timestamp + labels
labels: string;
labels: LogsStreamLabels;
logLevel: LogLevel;
searchWords?: string[];
timestamp: string; // ISO with nanosec precision
timeFromNow: string;
timeEpochMs: number;
timeLocal: string;
uniqueLabels?: string;
uniqueLabels?: LogsStreamLabels;
}
export enum LogsMetaKind {
Number,
String,
LabelsMap,
}
export interface LogsMetaItem {
label: string;
value: string;
value: string | number | LogsStreamLabels;
kind: LogsMetaKind;
}
export interface LogsModel {
......@@ -61,7 +68,7 @@ export interface LogsStream {
entries: LogsStreamEntry[];
search?: string;
parsedLabels?: LogsStreamLabels;
uniqueLabels?: string;
uniqueLabels?: LogsStreamLabels;
}
export interface LogsStreamEntry {
......
......@@ -429,8 +429,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
);
};
onClickTableCell = (columnKey: string, rowValue: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
onClickLabel = (key: string, value: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', key, value });
};
onModifyQueries = (action, index?: number) => {
......@@ -931,7 +931,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
isOpen={showingTable}
onToggle={this.onClickTableButton}
>
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickLabel} />
</Panel>
)}
{supportsLogs && (
......@@ -941,6 +941,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
loading={logsLoading}
position={position}
onChangeTime={this.onChangeTime}
onClickLabel={this.onClickLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
range={range}
......
import _ from 'lodash';
import React, { Fragment, PureComponent } from 'react';
import Highlighter from 'react-highlight-words';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange } from 'app/types/series';
import { LogsDedupStrategy, LogsModel, dedupLogRows, filterLogLevels, LogLevel } from 'app/core/logs_model';
import {
LogsDedupStrategy,
LogsModel,
dedupLogRows,
filterLogLevels,
LogLevel,
LogsStreamLabels,
LogsMetaKind,
} from 'app/core/logs_model';
import { findHighlightChunksInText } from 'app/core/utils/text';
import { Switch } from 'app/core/components/Switch/Switch';
......@@ -23,6 +32,51 @@ const graphOptions = {
},
};
function renderMetaItem(value: any, kind: LogsMetaKind) {
if (kind === LogsMetaKind.LabelsMap) {
return (
<span className="logs-meta-item__value-labels">
<Labels labels={value} />
</span>
);
}
return value;
}
class Label extends PureComponent<{
label: string;
value: string;
onClickLabel?: (label: string, value: string) => void;
}> {
onClickLabel = () => {
const { onClickLabel, label, value } = this.props;
if (onClickLabel) {
onClickLabel(label, value);
}
};
render() {
const { label, value } = this.props;
const tooltip = `${label}: ${value}`;
return (
<span className="logs-label" title={tooltip} onClick={this.onClickLabel}>
{value}
</span>
);
}
}
class Labels extends PureComponent<{
labels: LogsStreamLabels;
onClickLabel?: (label: string, value: string) => void;
}> {
render() {
const { labels, onClickLabel } = this.props;
return Object.keys(labels).map(key => (
<Label key={key} label={key} value={labels[key]} onClickLabel={onClickLabel} />
));
}
}
interface LogsProps {
className?: string;
data: LogsModel;
......@@ -32,6 +86,7 @@ interface LogsProps {
scanning?: boolean;
scanRange?: RawTimeRange;
onChangeTime?: (range: RawTimeRange) => void;
onClickLabel?: (label: string, value: string) => void;
onStartScanning?: () => void;
onStopScanning?: () => void;
}
......@@ -39,7 +94,7 @@ interface LogsProps {
interface LogsState {
dedup: LogsDedupStrategy;
hiddenLogLevels: Set<LogLevel>;
showLabels: boolean;
showLabels: boolean | null; // Tristate: null means auto
showLocalTime: boolean;
showUtc: boolean;
}
......@@ -48,7 +103,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
state = {
dedup: LogsDedupStrategy.none,
hiddenLogLevels: new Set(),
showLabels: true,
showLabels: null,
showLocalTime: true,
showUtc: false,
};
......@@ -99,9 +154,12 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
};
render() {
const { className = '', data, loading = false, position, range, scanning, scanRange } = this.props;
const { dedup, hiddenLogLevels, showLabels, showLocalTime, showUtc } = this.state;
const { className = '', data, loading = false, onClickLabel, position, range, scanning, scanRange } = this.props;
const { dedup, hiddenLogLevels, showLocalTime, showUtc } = this.state;
let { showLabels } = this.state;
const hasData = data && data.rows && data.rows.length > 0;
// Filtering
const filteredData = filterLogLevels(data, hiddenLogLevels);
const dedupedData = dedupLogRows(filteredData, dedup);
const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0);
......@@ -109,9 +167,17 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
if (dedup !== LogsDedupStrategy.none) {
meta.push({
label: 'Dedup count',
value: String(dedupCount),
value: dedupCount,
kind: LogsMetaKind.Number,
});
}
// Check for labels
if (showLabels === null && hasData) {
showLabels = data.rows.some(row => _.size(row.uniqueLabels) > 0);
}
// Grid options
const cssColumnSizes = ['3px']; // Log-level indicator line
if (showUtc) {
cssColumnSizes.push('minmax(100px, max-content)');
......@@ -177,7 +243,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
{meta.map(item => (
<div className="logs-meta-item" key={item.label}>
<span className="logs-meta-item__label">{item.label}:</span>
<span className="logs-meta-item__value">{item.value}</span>
<span className="logs-meta-item__value">{renderMetaItem(item.value, item.kind)}</span>
</div>
))}
</div>
......@@ -201,8 +267,8 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
{showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>}
{showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>}
{showLabels && (
<div className="max-width" title={row.labels}>
{row.labels}
<div className="logs-row-labels">
<Labels labels={row.uniqueLabels} onClickLabel={onClickLabel} />
</div>
)}
<div>
......
......@@ -3,9 +3,11 @@ import _ from 'lodash';
import * as dateMath from 'app/core/utils/datemath';
import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
import { PluginMeta, DataQuery } from 'app/types';
import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query';
import LanguageProvider from './language_provider';
import { mergeStreamsToLogs } from './result_transformer';
import { formatQuery, parseQuery } from './query_utils';
export const DEFAULT_LIMIT = 1000;
......@@ -16,20 +18,6 @@ const DEFAULT_QUERY_PARAMS = {
query: '',
};
const selectorRegexp = /{[^{]*}/g;
export function parseQuery(input: string) {
const match = input.match(selectorRegexp);
let query = '';
let regexp = input;
if (match) {
query = match[0];
regexp = input.replace(selectorRegexp, '').trim();
}
return { query, regexp };
}
function serializeParams(data: any) {
return Object.keys(data)
.map(k => {
......@@ -114,6 +102,21 @@ export default class LoggingDatasource {
});
}
modifyQuery(query: DataQuery, action: any): DataQuery {
const parsed = parseQuery(query.expr || '');
let selector = parsed.query;
switch (action.type) {
case 'ADD_FILTER': {
selector = addLabelToSelector(selector, action.key, action.value);
break;
}
default:
break;
}
const expression = formatQuery(selector, parsed.regexp);
return { ...query, expr: expression };
}
getTime(date, roundUp) {
if (_.isString(date)) {
date = dateMath.parse(date, roundUp);
......
import { parseQuery } from './datasource';
import { parseQuery } from './query_utils';
describe('parseQuery', () => {
it('returns empty for empty string', () => {
......
const selectorRegexp = /{[^{]*}/g;
export function parseQuery(input: string) {
const match = input.match(selectorRegexp);
let query = '';
let regexp = input;
if (match) {
query = match[0];
regexp = input.replace(selectorRegexp, '').trim();
}
return { query, regexp };
}
export function formatQuery(selector: string, search: string): string {
return `${selector || ''} ${search || ''}`.trim();
}
......@@ -41,7 +41,7 @@ describe('parseLabels()', () => {
});
it('returns labels on labels string', () => {
expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: '"bar"', baz: '"42"' });
expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: 'bar', baz: '42' });
});
});
......@@ -52,7 +52,7 @@ describe('formatLabels()', () => {
});
it('returns label string on label set', () => {
expect(formatLabels({ foo: '"bar"', baz: '"42"' })).toEqual('{baz="42", foo="bar"}');
expect(formatLabels({ foo: 'bar', baz: '42' })).toEqual('{baz="42", foo="bar"}');
});
});
......@@ -63,14 +63,14 @@ describe('findCommonLabels()', () => {
});
it('returns no common labels on differing sets', () => {
expect(findCommonLabels([{ foo: '"bar"' }, {}])).toEqual({});
expect(findCommonLabels([{}, { foo: '"bar"' }])).toEqual({});
expect(findCommonLabels([{ baz: '42' }, { foo: '"bar"' }])).toEqual({});
expect(findCommonLabels([{ foo: '42', baz: '"bar"' }, { foo: '"bar"' }])).toEqual({});
expect(findCommonLabels([{ foo: 'bar' }, {}])).toEqual({});
expect(findCommonLabels([{}, { foo: 'bar' }])).toEqual({});
expect(findCommonLabels([{ baz: '42' }, { foo: 'bar' }])).toEqual({});
expect(findCommonLabels([{ foo: '42', baz: 'bar' }, { foo: 'bar' }])).toEqual({});
});
it('returns the single labels set as common labels', () => {
expect(findCommonLabels([{ foo: '"bar"' }])).toEqual({ foo: '"bar"' });
expect(findCommonLabels([{ foo: 'bar' }])).toEqual({ foo: 'bar' });
});
});
......@@ -106,10 +106,10 @@ describe('mergeStreamsToLogs()', () => {
expect(mergeStreamsToLogs([stream1]).rows).toMatchObject([
{
entry: 'WARN boooo',
labels: '{foo="bar"}',
labels: { foo: 'bar' },
key: 'EK1970-01-01T00:00:00Z{foo="bar"}',
logLevel: 'warning',
uniqueLabels: '',
uniqueLabels: {},
},
]);
});
......@@ -140,21 +140,21 @@ describe('mergeStreamsToLogs()', () => {
expect(mergeStreamsToLogs([stream1, stream2]).rows).toMatchObject([
{
entry: 'INFO 2',
labels: '{foo="bar", baz="2"}',
labels: { foo: 'bar', baz: '2' },
logLevel: 'info',
uniqueLabels: '{baz="2"}',
uniqueLabels: { baz: '2' },
},
{
entry: 'WARN boooo',
labels: '{foo="bar", baz="1"}',
labels: { foo: 'bar', baz: '1' },
logLevel: 'warning',
uniqueLabels: '{baz="1"}',
uniqueLabels: { baz: '1' },
},
{
entry: 'INFO 1',
labels: '{foo="bar", baz="2"}',
labels: { foo: 'bar', baz: '2' },
logLevel: 'info',
uniqueLabels: '{baz="2"}',
uniqueLabels: { baz: '2' },
},
]);
});
......
......@@ -9,6 +9,7 @@ import {
LogsStream,
LogsStreamEntry,
LogsStreamLabels,
LogsMetaKind,
} from 'app/core/logs_model';
import { DEFAULT_LIMIT } from './datasource';
......@@ -40,7 +41,7 @@ export function getLogLevel(line: string): LogLevel {
/**
* Regexp to extract Prometheus-style labels
*/
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
const labelRegexp = /\b(\w+)(!?=~?)"([^"\n]*?)"/g;
/**
* Returns a map of label keys to value from an input selector string.
......@@ -104,11 +105,17 @@ export function formatLabels(labels: LogsStreamLabels, defaultValue = ''): strin
return defaultValue;
}
const labelKeys = Object.keys(labels).sort();
const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(', ');
const cleanSelector = labelKeys.map(key => `${key}="${labels[key]}"`).join(', ');
return ['{', cleanSelector, '}'].join('');
}
export function processEntry(entry: LogsStreamEntry, labels: string, uniqueLabels: string, search: string): LogRow {
export function processEntry(
entry: LogsStreamEntry,
labels: string,
parsedLabels: LogsStreamLabels,
uniqueLabels: LogsStreamLabels,
search: string
): LogRow {
const { line, timestamp } = entry;
// Assumes unique-ness, needs nanosec precision for timestamp
const key = `EK${timestamp}${labels}`;
......@@ -120,13 +127,13 @@ export function processEntry(entry: LogsStreamEntry, labels: string, uniqueLabel
return {
key,
labels,
logLevel,
timeFromNow,
timeEpochMs,
timeLocal,
uniqueLabels,
entry: line,
labels: parsedLabels,
searchWords: search ? [search] : [],
timestamp: timestamp,
};
......@@ -141,7 +148,7 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT)
const commonLabels = findCommonLabels(streams.map(model => model.parsedLabels));
streams = streams.map(stream => ({
...stream,
uniqueLabels: formatLabels(findUniqueLabels(stream.parsedLabels, commonLabels)),
uniqueLabels: findUniqueLabels(stream.parsedLabels, commonLabels),
}));
// Merge stream entries into single list of log rows
......@@ -149,7 +156,9 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT)
.reduce(
(acc: LogRow[], stream: LogsStream) => [
...acc,
...stream.entries.map(entry => processEntry(entry, stream.labels, stream.uniqueLabels, stream.search)),
...stream.entries.map(entry =>
processEntry(entry, stream.labels, stream.parsedLabels, stream.uniqueLabels, stream.search)
),
],
[]
)
......@@ -162,13 +171,15 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT)
if (_.size(commonLabels) > 0) {
meta.push({
label: 'Common labels',
value: formatLabels(commonLabels),
value: commonLabels,
kind: LogsMetaKind.LabelsMap,
});
}
if (limit) {
meta.push({
label: 'Limit',
value: `${limit} (${sortedRows.length} returned)`,
kind: LogsMetaKind.String,
});
}
......
......@@ -49,7 +49,7 @@ export function addLabelToQuery(query: string, key: string, value: string, opera
const selectorWithLabel = addLabelToSelector(selector, key, value, operator);
lastIndex = match.index + match[1].length + 2;
suffix = query.slice(match.index + match[0].length);
parts.push(prefix, '{', selectorWithLabel, '}');
parts.push(prefix, selectorWithLabel);
match = selectorRegexp.exec(query);
}
......@@ -59,7 +59,7 @@ export function addLabelToQuery(query: string, key: string, value: string, opera
const labelRegexp = /(\w+)\s*(=|!=|=~|!~)\s*("[^"]*")/g;
function addLabelToSelector(selector: string, labelKey: string, labelValue: string, labelOperator?: string) {
export function addLabelToSelector(selector: string, labelKey: string, labelValue: string, labelOperator?: string) {
const parsedLabels = [];
// Split selector into labels
......@@ -76,13 +76,15 @@ function addLabelToSelector(selector: string, labelKey: string, labelValue: stri
parsedLabels.push({ key: labelKey, operator: operatorForLabelKey, value: `"${labelValue}"` });
// Sort labels by key and put them together
return _.chain(parsedLabels)
const formatted = _.chain(parsedLabels)
.uniqWith(_.isEqual)
.compact()
.sortBy('key')
.map(({ key, operator, value }) => `${key}${operator}${value}`)
.value()
.join(',');
return `{${formatted}}`;
}
function isPositionInsideChars(text: string, position: number, openChar: string, closeChar: string) {
......
import addLabelToQuery from '../add_label_to_query';
import { addLabelToQuery, addLabelToSelector } from '../add_label_to_query';
describe('addLabelToQuery()', () => {
it('should add label to simple query', () => {
......@@ -56,3 +56,16 @@ describe('addLabelToQuery()', () => {
);
});
});
describe('addLabelToSelector()', () => {
test('should add a label to an empty selector', () => {
expect(addLabelToSelector('{}', 'foo', 'bar')).toBe('{foo="bar"}');
expect(addLabelToSelector('', 'foo', 'bar')).toBe('{foo="bar"}');
});
test('should add a label to a selector', () => {
expect(addLabelToSelector('{foo="bar"}', 'baz', '42')).toBe('{baz="42",foo="bar"}');
});
test('should add a label to a selector with custom operator', () => {
expect(addLabelToSelector('{}', 'baz', '42', '!=')).toBe('{baz!="42"}');
});
});
......@@ -261,6 +261,8 @@
border-radius: $border-radius;
margin: 2*$panel-margin 0;
border: $panel-border;
justify-items: flex-start;
align-items: flex-start;
> * {
margin-right: 1em;
......@@ -276,11 +278,11 @@
.logs-meta {
flex: 1;
color: $text-color-weak;
padding: 2px 0;
// Align first line with controls labels
margin-top: -2px;
}
.logs-meta-item {
display: inline-block;
margin-right: 1em;
}
......@@ -294,6 +296,12 @@
font-family: $font-family-monospace;
}
.logs-meta-item__value-labels {
// compensate for the labels padding
position: relative;
top: 4px;
}
.logs-row-match-highlight {
// Undoing mark styling
background: inherit;
......@@ -356,6 +364,25 @@
background-color: #1f78c1;
margin: 0 1px 1px 0;
}
.logs-label {
display: inline-block;
padding: 0 2px;
background-color: $btn-inverse-bg;
border-radius: $border-radius;
margin-right: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logs-row-labels {
line-height: 1.2;
.logs-label {
cursor: pointer;
}
}
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment