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 ( + + +
+ {'Unable to match any context columns. Make sure your query returns at least one log context column from your '} + ClickHouse Data Source settings +
+
+
+ ); + } + + 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 ( +
+
+ +
test
+ {name} +
+ {value} +
+ ); +} + +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: [] }; }