diff --git a/CHANGELOG.md b/CHANGELOG.md
index a60744c9..dec5c5f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/components/LogContextPanel.test.tsx b/src/components/LogContextPanel.test.tsx
new file mode 100644
index 00000000..362dd2a8
--- /dev/null
+++ b/src/components/LogContextPanel.test.tsx
@@ -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();
+ 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();
+
+ 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();
+
+ 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');
+ });
+});
diff --git a/src/components/LogsContextPanel.tsx b/src/components/LogsContextPanel.tsx
new file mode 100644
index 00000000..3e96c37b
--- /dev/null
+++ b/src/components/LogsContextPanel.tsx
@@ -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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {columns.map((p) => (
+
+ ))}
+
+ )
+};
+
+/**
+ * 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 (
+
+ );
+}
+
+export default LogsContextPanel;
+
+export const _testExports = {
+ iconMatcher,
+ LogContextKey,
+};
diff --git a/src/components/configEditor/LogsConfig.test.tsx b/src/components/configEditor/LogsConfig.test.tsx
index 99d619b5..c9064f42 100644
--- a/src/components/configEditor/LogsConfig.test.tsx
+++ b/src/components/configEditor/LogsConfig.test.tsx
@@ -17,6 +17,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
@@ -34,6 +36,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
@@ -58,6 +62,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
@@ -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);
@@ -105,6 +115,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
@@ -129,6 +141,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={onTimeColumnChange}
onLevelColumnChange={() => {}}
onMessageColumnChange={() => {}}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
@@ -153,6 +167,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={onLevelColumnChange}
onMessageColumnChange={() => {}}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
@@ -177,6 +193,8 @@ describe('LogsConfig', () => {
onTimeColumnChange={() => {}}
onLevelColumnChange={() => {}}
onMessageColumnChange={onMessageColumnChange}
+ onSelectContextColumnsChange={() => {}}
+ onContextColumnsChange={() => {}}
/>
);
expect(result.container.firstChild).not.toBeNull();
diff --git a/src/components/configEditor/LogsConfig.tsx b/src/components/configEditor/LogsConfig.tsx
index f8a07269..eabd6f2e 100644
--- a/src/components/configEditor/LogsConfig.tsx
+++ b/src/components/configEditor/LogsConfig.tsx
@@ -1,6 +1,6 @@
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';
@@ -8,6 +8,7 @@ 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;
@@ -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;
@@ -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 (
{
placeholder={defaultLogsTable}
/>
-
@@ -110,6 +117,30 @@ export const LogsConfig = (props: LogsConfigProps) => {
onChange={onMessageColumnChange}
/>
+
+
+
+
+
+ {labels.contextColumns.columns.label}
+
+
+
+
);
}
diff --git a/src/components/queryBuilder/Switch.tsx b/src/components/queryBuilder/Switch.tsx
index 769f1c01..076b2454 100644
--- a/src/components/queryBuilder/Switch.tsx
+++ b/src/components/queryBuilder/Switch.tsx
@@ -8,10 +8,11 @@ interface SwitchProps {
label: string;
tooltip: string;
inline?: boolean;
+ wide?: boolean;
}
export const Switch = (props: SwitchProps) => {
- const { value, onChange, label, tooltip, inline } = props;
+ const { value, onChange, label, tooltip, inline, wide } = props;
const theme = useTheme();
const switchContainerStyle: React.CSSProperties = {
@@ -25,7 +26,7 @@ export const Switch = (props: SwitchProps) => {
return (
-
+
{label}
diff --git a/src/components/queryBuilder/views/LogsQueryBuilder.tsx b/src/components/queryBuilder/views/LogsQueryBuilder.tsx
index e5b90209..6a660762 100644
--- a/src/components/queryBuilder/views/LogsQueryBuilder.tsx
+++ b/src/components/queryBuilder/views/LogsQueryBuilder.tsx
@@ -94,7 +94,7 @@ export const LogsQueryBuilder = (props: LogsQueryBuilderProps) => {
}, builderState);
useLogDefaultsOnMount(datasource, isNewQuery, builderOptions, builderOptionsDispatch);
- useOtelColumns(builderState.otelEnabled, builderState.otelVersion, builderOptionsDispatch);
+ useOtelColumns(datasource, builderState.otelEnabled, builderState.otelVersion, builderOptionsDispatch);
useDefaultTimeColumn(datasource, allColumns, builderOptions.table, builderState.timeColumn, builderState.otelEnabled, builderOptionsDispatch);
useDefaultFilters(builderOptions.table, isNewQuery, builderOptionsDispatch);
diff --git a/src/components/queryBuilder/views/logsQueryBuilderHooks.test.ts b/src/components/queryBuilder/views/logsQueryBuilderHooks.test.ts
index 046fdfb2..83d95782 100644
--- a/src/components/queryBuilder/views/logsQueryBuilderHooks.test.ts
+++ b/src/components/queryBuilder/views/logsQueryBuilderHooks.test.ts
@@ -8,6 +8,9 @@ import otel from 'otel';
describe('useLogDefaultsOnMount', () => {
it('should call builderOptionsDispatch with default log columns', async () => {
const builderOptionsDispatch = jest.fn();
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(false);
+ // Should not be included, since shouldSelectLogContextColumns returns false
+ jest.spyOn(mockDatasource, 'getLogContextColumnNames').mockReturnValue(['SampleColumn']);
jest.spyOn(mockDatasource, 'getLogsOtelVersion').mockReturnValue(undefined);
jest.spyOn(mockDatasource, 'getDefaultLogsColumns').mockReturnValue(new Map
([[ColumnHint.Time, 'timestamp']]));
@@ -27,6 +30,33 @@ describe('useLogDefaultsOnMount', () => {
expect(builderOptionsDispatch).toHaveBeenCalledWith(expect.objectContaining(setOptions(expectedOptions)));
});
+ it('should call builderOptionsDispatch with default log columns, including log context columns', async () => {
+ const builderOptionsDispatch = jest.fn();
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(true);
+ // timestamp is included, but also provided as a Log Context column. It should only appear once.
+ jest.spyOn(mockDatasource, 'getLogContextColumnNames').mockReturnValue(['timestamp', 'SampleColumn']);
+ jest.spyOn(mockDatasource, 'getLogsOtelVersion').mockReturnValue(undefined);
+ jest.spyOn(mockDatasource, 'getDefaultLogsColumns').mockReturnValue(new Map([[ColumnHint.Time, 'timestamp']]));
+
+ renderHook(() => useLogDefaultsOnMount(mockDatasource, true, {} as QueryBuilderOptions, builderOptionsDispatch));
+
+ const expectedOptions = {
+ database: expect.anything(),
+ table: expect.anything(),
+ columns: [
+ { name: 'timestamp', hint: ColumnHint.Time },
+ { name: 'SampleColumn' }
+ ],
+ meta: {
+ otelEnabled: expect.anything(),
+ otelVersion: undefined
+ }
+ };
+
+ expect(builderOptionsDispatch).toHaveBeenCalledTimes(1);
+ expect(builderOptionsDispatch).toHaveBeenCalledWith(expect.objectContaining(setOptions(expectedOptions)));
+ });
+
it('should not call builderOptionsDispatch after defaults are set', async () => {
const builderOptions = {} as QueryBuilderOptions;
const builderOptionsDispatch = jest.fn();
@@ -50,24 +80,30 @@ describe('useOtelColumns', () => {
const testOtelVersion = otel.getLatestVersion();
it('should not call builderOptionsDispatch if OTEL is already enabled', async () => {
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(false);
const builderOptionsDispatch = jest.fn();
- renderHook(() => useOtelColumns(true, testOtelVersion.version, builderOptionsDispatch));
+
+ renderHook(() => useOtelColumns(mockDatasource, true, testOtelVersion.version, builderOptionsDispatch));
expect(builderOptionsDispatch).toHaveBeenCalledTimes(0);
});
it('should not call builderOptionsDispatch if OTEL is disabled', async () => {
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(false);
+ // Should not be included, since shouldSelectLogContextColumns returns false
+ jest.spyOn(mockDatasource, 'getLogContextColumnNames').mockReturnValue(['SampleColumn']);
const builderOptionsDispatch = jest.fn();
- renderHook(() => useOtelColumns(true, testOtelVersion.version, builderOptionsDispatch));
+ renderHook(() => useOtelColumns(mockDatasource, true, testOtelVersion.version, builderOptionsDispatch));
expect(builderOptionsDispatch).toHaveBeenCalledTimes(0);
});
it('should call builderOptionsDispatch with columns when OTEL is toggled on', async () => {
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(false);
const builderOptionsDispatch = jest.fn();
let otelEnabled = false;
- const hook = renderHook(enabled => useOtelColumns(enabled, testOtelVersion.version, builderOptionsDispatch), { initialProps: otelEnabled });
+ const hook = renderHook(enabled => useOtelColumns(mockDatasource, enabled, testOtelVersion.version, builderOptionsDispatch), { initialProps: otelEnabled });
otelEnabled = true;
hook.rerender(otelEnabled);
@@ -79,11 +115,32 @@ describe('useOtelColumns', () => {
expect(builderOptionsDispatch).toHaveBeenCalledWith(expect.objectContaining(setOptions(expectedOptions)));
});
+ it('should call builderOptionsDispatch with log context columns when auto-select is enabled', async () => {
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(true);
+ // Timestamp is an OTel column, but also provided as a Log Context column. It should only appear once.
+ jest.spyOn(mockDatasource, 'getLogContextColumnNames').mockReturnValue(['Timestamp', 'SampleColumn']);
+ const builderOptionsDispatch = jest.fn();
+
+ let otelEnabled = false;
+ const hook = renderHook(enabled => useOtelColumns(mockDatasource, enabled, testOtelVersion.version, builderOptionsDispatch), { initialProps: otelEnabled });
+ otelEnabled = true;
+ hook.rerender(otelEnabled);
+
+ const columns: SelectedColumn[] = [];
+ testOtelVersion.logColumnMap.forEach((v, k) => columns.push({ name: v, hint: k }));
+ columns.push({ name: 'SampleColumn' });
+ const expectedOptions = { columns };
+
+ expect(builderOptionsDispatch).toHaveBeenCalledTimes(1);
+ expect(builderOptionsDispatch).toHaveBeenCalledWith(expect.objectContaining(setOptions(expectedOptions)));
+ });
+
it('should not call builderOptionsDispatch after OTEL columns are set', async () => {
+ jest.spyOn(mockDatasource, 'shouldSelectLogContextColumns').mockReturnValue(false);
const builderOptionsDispatch = jest.fn();
let otelEnabled = false; // OTEL is off
- const hook = renderHook(enabled => useOtelColumns(enabled, testOtelVersion.version, builderOptionsDispatch), { initialProps: otelEnabled });
+ const hook = renderHook(enabled => useOtelColumns(mockDatasource, enabled, testOtelVersion.version, builderOptionsDispatch), { initialProps: otelEnabled });
otelEnabled = true;
hook.rerender(otelEnabled); // OTEL is on, columns are set
hook.rerender(otelEnabled); // OTEL still on, should not set again
@@ -134,7 +191,7 @@ describe('useDefaultTimeColumn', () => {
const timeColumn = undefined;
const otelEnabled = false;
- const hook = renderHook(table =>
+ const hook = renderHook(table =>
useDefaultTimeColumn(
mockDatasource,
allColumns,
diff --git a/src/components/queryBuilder/views/logsQueryBuilderHooks.ts b/src/components/queryBuilder/views/logsQueryBuilderHooks.ts
index acfb8b4a..ee04ba9c 100644
--- a/src/components/queryBuilder/views/logsQueryBuilderHooks.ts
+++ b/src/components/queryBuilder/views/logsQueryBuilderHooks.ts
@@ -21,8 +21,23 @@ export const useLogDefaultsOnMount = (datasource: Datasource, isNewQuery: boolea
const defaultColumns = datasource.getDefaultLogsColumns();
const nextColumns: SelectedColumn[] = [];
+ const includedColumns = new Set();
for (let [hint, colName] of defaultColumns) {
nextColumns.push({ name: colName, hint });
+ includedColumns.add(colName);
+ }
+
+ if (datasource.shouldSelectLogContextColumns()) {
+ const contextColumnNames = datasource.getLogContextColumnNames();
+
+ for (let columnName of contextColumnNames) {
+ if (includedColumns.has(columnName)) {
+ continue;
+ }
+
+ nextColumns.push({ name: columnName });
+ includedColumns.add(columnName);
+ }
}
builderOptionsDispatch(setOptions({
@@ -42,7 +57,7 @@ export const useLogDefaultsOnMount = (datasource: Datasource, isNewQuery: boolea
* Sets OTEL Logs columns automatically when OTEL is enabled.
* Does not run if OTEL is already enabled, only when it's changed.
*/
-export const useOtelColumns = (otelEnabled: boolean, otelVersion: string, builderOptionsDispatch: React.Dispatch) => {
+export const useOtelColumns = (datasource: Datasource, otelEnabled: boolean, otelVersion: string, builderOptionsDispatch: React.Dispatch) => {
const didSetColumns = useRef(otelEnabled);
if (!otelEnabled) {
didSetColumns.current = false;
@@ -60,13 +75,28 @@ export const useOtelColumns = (otelEnabled: boolean, otelVersion: string, builde
}
const columns: SelectedColumn[] = [];
+ const includedColumns = new Set();
logColumnMap.forEach((name, hint) => {
columns.push({ name, hint });
+ includedColumns.add(name);
});
+ if (datasource.shouldSelectLogContextColumns()) {
+ const contextColumnNames = datasource.getLogContextColumnNames();
+
+ for (let columnName of contextColumnNames) {
+ if (includedColumns.has(columnName)) {
+ continue;
+ }
+
+ columns.push({ name: columnName });
+ includedColumns.add(columnName);
+ }
+ }
+
builderOptionsDispatch(setOptions({ columns }));
didSetColumns.current = true;
- }, [otelEnabled, otelVersion, builderOptionsDispatch]);
+ }, [datasource, otelEnabled, otelVersion, builderOptionsDispatch]);
};
// Finds and selects a default log time column, updates when table changes
diff --git a/src/data/CHDatasource.ts b/src/data/CHDatasource.ts
index cc95331d..ad41970b 100644
--- a/src/data/CHDatasource.ts
+++ b/src/data/CHDatasource.ts
@@ -8,15 +8,18 @@ import {
DataSourceWithSupplementaryQueriesSupport,
getTimeZone,
getTimeZoneInfo,
+ LogRowContextOptions,
+ LogRowContextQueryDirection,
LogRowModel,
MetricFindValue,
QueryFixAction,
ScopedVars,
+ SupplementaryQueryOptions,
SupplementaryQueryType,
TypedVariableModel,
} from '@grafana/data';
import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime';
-import { Observable, map } from 'rxjs';
+import { Observable, map, firstValueFrom } from 'rxjs';
import { CHConfig } from 'types/config';
import { EditorType, CHQuery } from 'types/sql';
import {
@@ -45,9 +48,10 @@ import {
} from './logs';
import { generateSql, getColumnByHint, logAliasToColumnHints } from './sqlGenerator';
import otel from 'otel';
-import { ReactNode } from 'react';
+import { createElement as createReactElement, ReactNode } from 'react';
import { dataFrameHasLogLabelWithName, transformQueryResponseWithTraceAndLogLinks } from './utils';
import { pluginVersion } from 'utils/version';
+import LogsContextPanel from 'components/LogsContextPanel';
export class Datasource
extends DataSourceWithBackend
@@ -195,7 +199,7 @@ export class Datasource
};
}
- getSupplementaryQuery(): CHQuery | undefined {
+ getSupplementaryQuery(options: SupplementaryQueryOptions, originalQuery: CHQuery): CHQuery | undefined {
return undefined;
}
@@ -441,6 +445,14 @@ export class Datasource
return result;
}
+ shouldSelectLogContextColumns(): boolean {
+ return this.settings.jsonData.logs?.selectContextColumns || false;
+ }
+
+ getLogContextColumnNames(): string[] {
+ return this.settings.jsonData.logs?.contextColumns || [];
+ }
+
/**
* Get configured OTEL version for logs. Returns undefined when versioning is disabled/unset.
*/
@@ -762,16 +774,141 @@ export class Datasource
}
// interface DataSourceWithLogsContextSupport
- async getLogRowContext(row: LogRowModel, options?: any | undefined, query?: CHQuery | undefined): Promise {
- return {} as DataQueryResponse;
+ getLogContextColumnsFromLogRow(row: LogRowModel): LogContextColumn[] {
+ const contextColumnNames = this.getLogContextColumnNames();
+ const contextColumns: LogContextColumn[] = [];
+
+ for (let columnName of contextColumnNames) {
+ const isMapKey = columnName.includes('[\'') && columnName.includes('\']');
+ let mapName = '';
+ let keyName = '';
+ if (isMapKey) {
+ mapName = columnName.substring(0, columnName.indexOf('['));
+ keyName = columnName.substring(columnName.indexOf('[\'') + 2, columnName.lastIndexOf('\']'));
+ }
+
+ const field = row.dataFrame.fields.find(f => (
+ // exact column name match
+ f.name === columnName ||
+ (isMapKey && (
+ // entire map was selected
+ f.name === mapName ||
+ // single key was selected from map
+ f.name === `arrayElement(${mapName}, '${keyName}')`
+ ))
+ ));
+ if (!field) {
+ continue;
+ }
+
+ let value = field.values.get(row.rowIndex);
+ if (value && field.type === 'other' && isMapKey) {
+ value = value[keyName];
+ }
+
+ if (!value) {
+ continue;
+ }
+
+ let contextColumnName: string;
+ if (isMapKey) {
+ contextColumnName = `${mapName}['${keyName}']`;
+ } else {
+ contextColumnName = columnName;
+ }
+
+ contextColumns.push({
+ name: contextColumnName,
+ value
+ });
+ }
+
+ return contextColumns;
+ }
+
+
+ /**
+ * Runs a query based on a single log row and a direction (forward/backward)
+ *
+ * Will remove all filters and ORDER BYs, and will re-add them based on the configured context columns.
+ * Context columns are used to narrow down to a single logging unit as defined by your logging infrastructure.
+ * Typically this will be a single service, or container/pod in docker/k8s.
+ *
+ * If no context columns can be matched from the selected data frame, then the query is not run.
+ */
+ async getLogRowContext(row: LogRowModel, options?: LogRowContextOptions, query?: CHQuery | undefined, cacheFilters?: boolean): Promise {
+ if (!query) {
+ throw new Error('Missing query for log context');
+ } else if (!options || !options.direction || options.limit === undefined) {
+ throw new Error('Missing log context options for query');
+ } else if (query.editorType === EditorType.SQL || !query.builderOptions) {
+ throw new Error('Log context feature only works for builder queries');
+ }
+
+ const contextQuery = cloneDeep(query);
+ contextQuery.refId = '';
+ const builderOptions = contextQuery.builderOptions;
+ builderOptions.limit = options.limit;
+
+ if (!getColumnByHint(builderOptions, ColumnHint.Time)) {
+ throw new Error('Missing time column for log context');
+ }
+
+ builderOptions.orderBy = [];
+ builderOptions.orderBy.push({
+ name: '',
+ hint: ColumnHint.Time,
+ dir: options.direction === LogRowContextQueryDirection.Forward ? OrderByDirection.ASC : OrderByDirection.DESC
+ });
+
+ builderOptions.filters = [];
+ builderOptions.filters.push({
+ operator: options.direction === LogRowContextQueryDirection.Forward ? FilterOperator.GreaterThanOrEqual : FilterOperator.LessThanOrEqual,
+ filterType: 'custom',
+ hint: ColumnHint.Time,
+ key: '',
+ value: `fromUnixTimestamp64Nano(${row.timeEpochNs})`,
+ type: 'datetime',
+ condition: 'AND'
+ });
+
+ const contextColumns = this.getLogContextColumnsFromLogRow(row);
+ if (contextColumns.length < 1) {
+ throw new Error('Unable to match any log context columns');
+ }
+
+ const contextColumnFilters: Filter[] = contextColumns.map(c => ({
+ operator: FilterOperator.Equals,
+ filterType: 'custom',
+ key: c.name,
+ value: c.value,
+ type: 'string',
+ condition: 'AND'
+ }));
+ builderOptions.filters.push(...contextColumnFilters);
+
+ contextQuery.rawSql = generateSql(builderOptions);
+ const req = {
+ targets: [contextQuery],
+ } as DataQueryRequest;
+
+ return await firstValueFrom(this.query(req));
}
+ /**
+ * Unused + deprecated but required by interface, log context button is always visible now
+ * https://github.com/grafana/grafana/issues/66819
+ */
showContextToggle(row?: LogRowModel): boolean {
- return false;
+ return true;
}
- getLogRowContextUi(row: LogRowModel, runContextQuery?: (() => void) | undefined): ReactNode {
- return false;
+ /**
+ * Returns a React component that is displayed in the top portion of the log context panel
+ */
+ getLogRowContextUi(row: LogRowModel, runContextQuery?: (() => void) | undefined, query?: CHQuery | undefined): ReactNode {
+ const contextColumns = this.getLogContextColumnsFromLogRow(row);
+ return createReactElement(LogsContextPanel, { columns: contextColumns, datasourceUid: this.uid });
}
}
@@ -790,3 +927,8 @@ interface Tags {
type?: TagType;
frame: DataFrame;
}
+
+export interface LogContextColumn {
+ name: string;
+ value: string;
+}
diff --git a/src/labels.ts b/src/labels.ts
index 0c164317..02a7f405 100644
--- a/src/labels.ts
+++ b/src/labels.ts
@@ -207,17 +207,31 @@ export default {
description: 'Default columns for log queries. Leave empty to disable.',
time: {
- label: 'Time column',
- tooltip: 'Column for the log timestamp'
- },
- level: {
- label: 'Log Level column',
- tooltip: 'Column for the log level'
- },
- message: {
- label: 'Log Message column',
- tooltip: 'Column for log message'
- }
+ label: 'Time column',
+ tooltip: 'Column for the log timestamp'
+ },
+ level: {
+ label: 'Log Level column',
+ tooltip: 'Column for the log level'
+ },
+ message: {
+ label: 'Log Message column',
+ tooltip: 'Column for log message'
+ }
+ },
+ contextColumns: {
+ title: 'Context columns',
+ description: 'These columns are used to narrow down a single log row to its original service/container/pod source. At least one is required for the log context feature to work.',
+
+ selectContextColumns: {
+ label: 'Auto-Select Columns',
+ tooltip: 'When enabled, will always include context columns in log queries'
+ },
+ columns: {
+ label: 'Context Columns',
+ tooltip: 'Comma separated list of column names to use for identifying a log\'s source',
+ placeholder: 'Column name (enter key to add)'
+ },
}
}
},
diff --git a/src/selectors.ts b/src/selectors.ts
index 40c29dd3..173938aa 100644
--- a/src/selectors.ts
+++ b/src/selectors.ts
@@ -133,6 +133,10 @@ export const Components = {
aliasTableInput: 'config__alias-table-config__alias-table-input',
}
},
+ LogsContextPanel: {
+ alert: 'logs-context-panel__alert',
+ LogsContextKey: 'logs-context-panel__logs-context-key',
+ },
QueryBuilder: {
expandBuilderButton: 'query-builder__expand-builder-button',
LogsQueryBuilder: {
diff --git a/src/types/config.ts b/src/types/config.ts
index b8ba1b74..3f976bb6 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -68,6 +68,9 @@ export interface CHLogsConfig {
timeColumn?: string;
levelColumn?: string;
messageColumn?: string;
+
+ selectContextColumns?: boolean;
+ contextColumns?: string[];
}
export interface CHTracesConfig {
diff --git a/src/views/CHConfigEditor.tsx b/src/views/CHConfigEditor.tsx
index 8fb8220b..f14cd0c1 100644
--- a/src/views/CHConfigEditor.tsx
+++ b/src/views/CHConfigEditor.tsx
@@ -134,7 +134,7 @@ export const ConfigEditor: React.FC = (props) => {
},
});
};
- const onLogsConfigChange = (key: keyof CHLogsConfig, value: string | boolean) => {
+ const onLogsConfigChange = (key: keyof CHLogsConfig, value: string | boolean | string[]) => {
onOptionsChange({
...options,
jsonData: {
@@ -423,6 +423,8 @@ export const ConfigEditor: React.FC = (props) => {
onTimeColumnChange={c => onLogsConfigChange('timeColumn', c)}
onLevelColumnChange={c => onLogsConfigChange('levelColumn', c)}
onMessageColumnChange={c => onLogsConfigChange('messageColumn', c)}
+ onSelectContextColumnsChange={c => onLogsConfigChange('selectContextColumns', c)}
+ onContextColumnsChange={c => onLogsConfigChange('contextColumns', c)}
/>
diff --git a/src/views/CHConfigEditorHooks.test.ts b/src/views/CHConfigEditorHooks.test.ts
index 97bf0f79..4a5d0d98 100644
--- a/src/views/CHConfigEditorHooks.test.ts
+++ b/src/views/CHConfigEditorHooks.test.ts
@@ -95,7 +95,9 @@ describe('useConfigDefaults', () => {
version: pluginVersion,
protocol: Protocol.Native,
logs: {
- defaultTable: defaultLogsTable
+ defaultTable: defaultLogsTable,
+ selectContextColumns: true,
+ contextColumns: []
},
traces: {
defaultTable: defaultTraceTable
diff --git a/src/views/CHConfigEditorHooks.ts b/src/views/CHConfigEditorHooks.ts
index efdf5067..1f625904 100644
--- a/src/views/CHConfigEditorHooks.ts
+++ b/src/views/CHConfigEditorHooks.ts
@@ -96,7 +96,9 @@ export const useConfigDefaults = (options: DataSourceSettings, onOptio
if (!jsonData.logs || jsonData.logs.defaultTable === undefined) {
jsonData.logs = {
...jsonData.logs,
- defaultTable: defaultLogsTable
+ defaultTable: defaultLogsTable,
+ selectContextColumns: true,
+ contextColumns: []
};
}