Skip to content

Commit

Permalink
Log Context (#992)
Browse files Browse the repository at this point in the history
  • Loading branch information
SpencerTorres authored Oct 11, 2024
1 parent 5881f29 commit b42eb03
Show file tree
Hide file tree
Showing 16 changed files with 564 additions and 37 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features

- Implemented log context for log queries
- Added configuration options for log context columns
- Queries parsed from the SQL editor will now attempt to re-map columns into their correct fields for Log and Trace queries.

### Fixes
Expand Down
57 changes: 57 additions & 0 deletions src/components/LogContextPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render } from '@testing-library/react';
import LogsContextPanel, { _testExports } from './LogsContextPanel';
import { Components } from 'selectors';

describe('LogsContextPanel', () => {
it('shows an alert when no columns are matched', () => {
const result = render(<LogsContextPanel columns={[]} datasourceUid="test-uid" />);
expect(result.getByTestId(Components.LogsContextPanel.alert)).toBeInTheDocument();
});

it('renders LogContextKey components for each column', () => {
const mockColumns = [
{ name: 'host', value: '127.0.0.1' },
{ name: 'service', value: 'test-api' },
];

const result = render(<LogsContextPanel columns={mockColumns} datasourceUid="test-uid" />);

expect(result.getAllByTestId(Components.LogsContextPanel.LogsContextKey)).toHaveLength(2);
expect(result.getByText('host')).toBeInTheDocument();
expect(result.getByText('127.0.0.1')).toBeInTheDocument();
expect(result.getByText('service')).toBeInTheDocument();
expect(result.getByText('test-api')).toBeInTheDocument();
});
});

describe('LogContextKey', () => {
const LogContextKey = _testExports.LogContextKey;

it('renders the expected keys', () => {
const props = {
name: 'testName',
value: 'testValue',
primaryColor: '#000',
primaryTextColor: '#aaa',
secondaryColor: '#111',
secondaryTextColor: '#bbb',
};


const result = render(<LogContextKey {...props} />);

expect(result.getByTestId(Components.LogsContextPanel.LogsContextKey)).toBeInTheDocument();
expect(result.getByText('testName')).toBeInTheDocument();
expect(result.getByText('testValue')).toBeInTheDocument();
});
});

describe('iconMatcher', () => {
const iconMatcher = _testExports.iconMatcher;

it('returns correct icons for different context names', () => {
expect(iconMatcher('database')).toBe('database');
expect(iconMatcher('???')).toBe('align-left');
});
});
162 changes: 162 additions & 0 deletions src/components/LogsContextPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React from 'react';
import { Alert, Icon, IconName, Stack, useTheme2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { LogContextColumn } from 'data/CHDatasource';
import { Components } from 'selectors';


const LogsContextPanelStyles = css`
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
width: 100%;
`;

interface LogContextPanelProps {
columns: LogContextColumn[];
datasourceUid: string;
}

const LogsContextPanel = (props: LogContextPanelProps) => {
const { columns, datasourceUid } = props;
const theme = useTheme2();

if (!columns || columns.length === 0) {
return (
<Alert data-testid={Components.LogsContextPanel.alert} title="" severity="warning">
<Stack direction="column">
<div>
{'Unable to match any context columns. Make sure your query returns at least one log context column from your '}
<a style={{ textDecoration: 'underline' }} href={`/connections/datasources/edit/${encodeURIComponent(datasourceUid)}#logs-config`}>ClickHouse Data Source settings</a>
</div>
</Stack>
</Alert>
);
}

return (
<div className={LogsContextPanelStyles}>
{columns.map((p) => (
<LogContextKey
key={p.name}
name={p.name}
value={p.value}
primaryColor={theme.colors.secondary.main}
primaryTextColor={theme.colors.text.primary}
secondaryColor={theme.colors.background.secondary}
secondaryTextColor={theme.colors.info.text}
/>
))}
</div>
)
};

/**
* Roughly match an icon with the context column name.
*/
const iconMatcher = (contextName: string): IconName => {
contextName = contextName.toLowerCase();

if (contextName === 'db' || contextName === 'database' || contextName.includes('data')) {
return 'database';
} else if (contextName.includes('service')) {
return 'building';
} else if (contextName.includes('error') || contextName.includes('warn') || contextName.includes('critical') || contextName.includes('fatal')) {
return 'exclamation-triangle';
} else if (contextName.includes('user') || contextName.includes('admin')) {
return 'user';
} else if (contextName.includes('email')) {
return 'at';
} else if (contextName.includes('file')) {
return 'file-alt';
} else if (contextName.includes('bug')) {
return 'bug';
} else if (contextName.includes('search')) {
return 'search';
} else if (contextName.includes('tag')) {
return 'tag-alt';
} else if (contextName.includes('span') || contextName.includes('stack')) {
return 'brackets-curly';
} if (contextName === 'host' || contextName === 'hostname' || contextName.includes('host')) {
return 'cloud';
} if (contextName === 'url' || contextName.includes('url')) {
return 'link';
} else if (contextName.includes('container') || contextName.includes('pod')) {
return 'cube';
}

return 'align-left';
}

interface LogContextKeyProps {
name: string;
value: string;
primaryColor: string;
primaryTextColor: string;
secondaryColor: string;
secondaryTextColor: string;
}

const LogContextKey = (props: LogContextKeyProps) => {
const { name, value, primaryColor, primaryTextColor, secondaryColor, secondaryTextColor } = props;

const styles = {
container: css`
display: flex;
justify-content: center;
align-items: center;
margin: 0.25em;
color: ${primaryTextColor}
`,
containerLeft: css`
display: flex;
align-items: center;
background-color: ${primaryColor};
border-radius: 2px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-top: 0.15em;
padding-bottom: 0.15em;
padding-left: 0.25em;
padding-right: 0.25em;
`,
contextName: css`
font-weight: bold;
padding-left: 0.25em;
user-select: all;
`,
contextValue: css`
background-color: ${secondaryColor};
color: ${secondaryTextColor};
border-radius: 2px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
user-select: all;
font-family: monospace;
padding-top: 0.15em;
padding-bottom: 0.15em;
padding-left: 0.25em;
padding-right: 0.25em;
`,
};

return (
<div className={styles.container} data-testid={Components.LogsContextPanel.LogsContextKey}>
<div className={styles.containerLeft}>
<Icon name={iconMatcher(name)} size="md" />
<div>test</div>
<span className={styles.contextName}>{name}</span>
</div>
<span className={styles.contextValue}>{value}</span>
</div>
);
}

export default LogsContextPanel;

export const _testExports = {
iconMatcher,
LogContextKey,
};
20 changes: 19 additions & 1 deletion src/components/configEditor/LogsConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand All @@ -34,6 +36,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand All @@ -58,6 +62,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand All @@ -82,11 +88,15 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();

const input = result.getByRole('checkbox');
const checkboxes = result.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(2);
const input = checkboxes[0];
expect(input).toBeInTheDocument();
fireEvent.click(input);
expect(onOtelEnabledChange).toHaveBeenCalledTimes(1);
Expand All @@ -105,6 +115,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand All @@ -129,6 +141,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={onTimeColumnChange}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand All @@ -153,6 +167,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={onLevelColumnChange}
onMessageColumnChange={() => {}}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand All @@ -177,6 +193,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={onMessageColumnChange}
onSelectContextColumnsChange={() => {}}
onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
Expand Down
39 changes: 35 additions & 4 deletions src/components/configEditor/LogsConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react';
import { ConfigSection, ConfigSubSection } from 'components/experimental/ConfigSection';
import { Input, Field } from '@grafana/ui';
import { Input, Field, InlineFormLabel, TagsInput } from '@grafana/ui';
import { OtelVersionSelect } from 'components/queryBuilder/OtelVersionSelect';
import { ColumnHint } from 'types/queryBuilder';
import otel, { defaultLogsTable } from 'otel';
import { LabeledInput } from './LabeledInput';
import { CHLogsConfig } from 'types/config';
import allLabels from 'labels';
import { columnLabelToPlaceholder } from 'data/utils';
import { Switch } from 'components/queryBuilder/Switch';

interface LogsConfigProps {
logsConfig?: CHLogsConfig;
Expand All @@ -18,18 +19,22 @@ interface LogsConfigProps {
onTimeColumnChange: (v: string) => void;
onLevelColumnChange: (v: string) => void;
onMessageColumnChange: (v: string) => void;
onSelectContextColumnsChange: (v: boolean) => void;
onContextColumnsChange: (v: string[]) => void;
}

export const LogsConfig = (props: LogsConfigProps) => {
const {
onDefaultDatabaseChange, onDefaultTableChange,
onOtelEnabledChange, onOtelVersionChange,
onTimeColumnChange, onLevelColumnChange, onMessageColumnChange
onTimeColumnChange, onLevelColumnChange, onMessageColumnChange,
onSelectContextColumnsChange, onContextColumnsChange
} = props;
let {
defaultDatabase, defaultTable,
otelEnabled, otelVersion,
timeColumn, levelColumn, messageColumn
timeColumn, levelColumn, messageColumn,
selectContextColumns, contextColumns
} = (props.logsConfig || {});
const labels = allLabels.components.Config.LogsConfig;

Expand All @@ -40,6 +45,8 @@ export const LogsConfig = (props: LogsConfigProps) => {
messageColumn = otelConfig.logColumnMap.get(ColumnHint.LogMessage);
}

const onContextColumnsChangeTrimmed = (columns: string[]) => onContextColumnsChange(columns.map(c => c.trim()).filter(c => c));

return (
<ConfigSection
title={labels.title}
Expand Down Expand Up @@ -74,7 +81,7 @@ export const LogsConfig = (props: LogsConfigProps) => {
placeholder={defaultLogsTable}
/>
</Field>
<ConfigSubSection
<ConfigSubSection
title={labels.columns.title}
description={labels.columns.description}
>
Expand Down Expand Up @@ -110,6 +117,30 @@ export const LogsConfig = (props: LogsConfigProps) => {
onChange={onMessageColumnChange}
/>
</ConfigSubSection>
<br/>
<ConfigSubSection
title={labels.contextColumns.title}
description={labels.contextColumns.description}
>
<Switch
label={labels.contextColumns.selectContextColumns.label}
tooltip={labels.contextColumns.selectContextColumns.tooltip}
value={selectContextColumns || false}
onChange={onSelectContextColumnsChange}
wide
/>
<div className="gf-form">
<InlineFormLabel width={12} className="query-keyword" tooltip={labels.contextColumns.columns.tooltip}>
{labels.contextColumns.columns.label}
</InlineFormLabel>
<TagsInput
placeholder={labels.contextColumns.columns.placeholder}
tags={contextColumns || []}
onChange={onContextColumnsChangeTrimmed}
width={60}
/>
</div>
</ConfigSubSection>
</ConfigSection>
);
}
Loading

0 comments on commit b42eb03

Please sign in to comment.