[Discover] Format JSON messages in Observability Logs profile (#205666)

## Summary

This PR updates the Observability Logs profile to detect and auto format
JSON message values within both the Log overview doc viewer tab and the
Summary cell popover. Additionally, it enables CTRL/CMD + F find
functionality within the doc viewer JSON tab for all contexts to make it
easier for users to search the JSON output.

JSON message formatting:

![json](https://github.com/user-attachments/assets/a7c63afd-bef7-4050-b8cf-08e4f469ffa9)

JSON tab find functionality:

![find](https://github.com/user-attachments/assets/aac51e05-6126-4770-8976-0d9057bad557)

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Davis McPhee 2025-01-09 20:05:18 -04:00 committed by GitHub
parent d96168c64f
commit 518e0afbde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 192 additions and 92 deletions

View file

@ -10,7 +10,12 @@
import React from 'react';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { render, screen } from '@testing-library/react';
import SummaryColumn, { SummaryColumnFactoryDeps, SummaryColumnProps } from './summary_column';
import SummaryColumn, {
AllSummaryColumnProps,
SummaryCellPopover,
SummaryColumnFactoryDeps,
SummaryColumnProps,
} from './summary_column';
import { DataGridDensity, ROWS_HEIGHT_OPTIONS } from '@kbn/unified-data-table';
import * as constants from '@kbn/discover-utils/src/data_types/logs/constants';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
@ -18,32 +23,46 @@ import { coreMock as corePluginMock } from '@kbn/core/public/mocks';
import { DataTableRecord, buildDataTableRecord } from '@kbn/discover-utils';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__/data_view';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
EuiCodeBlock: ({
children,
dangerouslySetInnerHTML,
}: {
children?: string;
dangerouslySetInnerHTML?: { __html: string };
}) => <code data-test-subj="codeBlock">{children ?? dangerouslySetInnerHTML?.__html ?? ''}</code>,
}));
const getSummaryProps = (
record: DataTableRecord,
opts: Partial<SummaryColumnProps & SummaryColumnFactoryDeps> = {}
): AllSummaryColumnProps => ({
rowIndex: 0,
colIndex: 0,
columnId: '_source',
isExpandable: true,
isExpanded: false,
isDetails: false,
row: record,
dataView: dataViewMock,
fieldFormats: fieldFormatsMock,
setCellProps: () => {},
closePopover: () => {},
density: DataGridDensity.COMPACT,
rowHeight: ROWS_HEIGHT_OPTIONS.single,
onFilter: jest.fn(),
shouldShowFieldHandler: () => true,
core: corePluginMock.createStart(),
share: sharePluginMock.createStartContract(),
...opts,
});
const renderSummary = (
record: DataTableRecord,
opts: Partial<SummaryColumnProps & SummaryColumnFactoryDeps> = {}
) => {
render(
<SummaryColumn
rowIndex={0}
colIndex={0}
columnId="_source"
isExpandable={true}
isExpanded={false}
isDetails={false}
row={record}
dataView={dataViewMock}
fieldFormats={fieldFormatsMock}
setCellProps={() => {}}
closePopover={() => {}}
density={DataGridDensity.COMPACT}
rowHeight={ROWS_HEIGHT_OPTIONS.single}
onFilter={jest.fn()}
shouldShowFieldHandler={() => true}
core={corePluginMock.createStart()}
share={sharePluginMock.createStartContract()}
{...opts}
/>
);
render(<SummaryColumn {...getSummaryProps(record, opts)} />);
};
const getBaseRecord = (overrides: Record<string, unknown> = {}) =>
@ -174,3 +193,18 @@ describe('SummaryColumn', () => {
});
});
});
describe('SummaryCellPopover', () => {
it('should render message value', async () => {
const message = 'This is a message';
render(<SummaryCellPopover {...getSummaryProps(getBaseRecord({ message }))} />);
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(message);
});
it('should render formatted JSON message value', async () => {
const json = { foo: { bar: true } };
const message = JSON.stringify(json);
render(<SummaryCellPopover {...getSummaryProps(getBaseRecord({ message }))} />);
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(JSON.stringify(json, null, 2));
});
});

View file

@ -98,14 +98,19 @@ const SummaryCell = ({
);
};
const SummaryCellPopover = (props: AllSummaryColumnProps) => {
export const SummaryCellPopover = (props: AllSummaryColumnProps) => {
const { row, dataView, fieldFormats, onFilter, closePopover, share, core } = props;
const resourceFields = createResourceFields(row, core, share);
const shouldRenderResource = resourceFields.length > 0;
const documentOverview = getLogDocumentOverview(row, { dataView, fieldFormats });
const { field, value } = getMessageFieldWithFallbacks(documentOverview);
const { field, value, formattedValue } = getMessageFieldWithFallbacks(documentOverview, {
includeFormattedValue: true,
});
const messageCodeBlockProps = formattedValue
? { language: 'json', children: formattedValue }
: { language: 'txt', dangerouslySetInnerHTML: { __html: value ?? '' } };
const shouldRenderContent = Boolean(field && value);
const shouldRenderSource = !shouldRenderContent;
@ -142,11 +147,9 @@ const SummaryCellPopover = (props: AllSummaryColumnProps) => {
overflowHeight={100}
paddingSize="s"
isCopyable
language="txt"
fontSize="s"
>
{value}
</EuiCodeBlock>
{...messageCodeBlockProps}
/>
</EuiFlexGroup>
)}
{shouldRenderSource && (

View file

@ -7,10 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { unescape } from 'lodash';
import { fieldConstants } from '..';
import { LogDocumentOverview } from '../types';
export const getMessageFieldWithFallbacks = (doc: LogDocumentOverview) => {
export const getMessageFieldWithFallbacks = (
doc: LogDocumentOverview,
{ includeFormattedValue = false }: { includeFormattedValue?: boolean } = {}
) => {
const rankingOrder = [
fieldConstants.MESSAGE_FIELD,
fieldConstants.ERROR_MESSAGE_FIELD,
@ -18,8 +22,20 @@ export const getMessageFieldWithFallbacks = (doc: LogDocumentOverview) => {
] as const;
for (const rank of rankingOrder) {
if (doc[rank] !== undefined && doc[rank] !== null) {
return { field: rank, value: doc[rank] };
const value = doc[rank];
if (value !== undefined && value !== null) {
let formattedValue: string | undefined;
if (includeFormattedValue) {
try {
formattedValue = JSON.stringify(JSON.parse(unescape(value)), null, 2);
} catch {
// If the value is not a valid JSON, leave it unformatted
}
}
return { field: rank, value, formattedValue };
}
}

View file

@ -18,6 +18,17 @@ import { setUnifiedDocViewerServices } from '../../plugin';
import { mockUnifiedDocViewerServices } from '../../__mocks__';
import { merge } from 'lodash';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
EuiCodeBlock: ({
children,
dangerouslySetInnerHTML,
}: {
children?: string;
dangerouslySetInnerHTML?: { __html: string };
}) => <code data-test-subj="codeBlock">{children ?? dangerouslySetInnerHTML?.__html ?? ''}</code>,
}));
const DATASET_NAME = 'logs.overview';
const NAMESPACE = 'default';
const DATA_STREAM_NAME = `logs-${DATASET_NAME}-${NAMESPACE}`;
@ -59,51 +70,55 @@ dataView.fields.getByName = (name: string) => {
return dataView.fields.getAll().find((field) => field.name === name);
};
const fullHit = buildDataTableRecord(
{
_index: DATA_STREAM_NAME,
_id: DATA_STREAM_NAME,
_score: 1,
_source: {
'@timestamp': NOW + 1000,
message: 'full document',
log: { level: 'info', file: { path: '/logs.overview.log' } },
data_stream: {
type: 'logs',
dataset: DATASET_NAME,
namespace: NAMESPACE,
const buildHit = (fields?: Record<string, unknown>) =>
buildDataTableRecord(
{
_index: DATA_STREAM_NAME,
_id: DATA_STREAM_NAME,
_score: 1,
_source: {
'@timestamp': NOW + 1000,
message: 'full document',
log: { level: 'info', file: { path: '/logs.overview.log' } },
data_stream: {
type: 'logs',
dataset: DATASET_NAME,
namespace: NAMESPACE,
},
'service.name': DATASET_NAME,
'host.name': 'gke-edge-oblt-pool',
'trace.id': 'abcdef',
orchestrator: {
cluster: {
id: 'my-cluster-id',
name: 'my-cluster-name',
},
resource: {
id: 'orchestratorResourceId',
},
},
cloud: {
provider: ['gcp'],
region: 'us-central-1',
availability_zone: MORE_THAN_1024_CHARS,
project: {
id: 'elastic-project',
},
instance: {
id: 'BgfderflkjTheUiGuy',
},
},
'agent.name': 'node',
...fields,
},
'service.name': DATASET_NAME,
'host.name': 'gke-edge-oblt-pool',
'trace.id': 'abcdef',
orchestrator: {
cluster: {
id: 'my-cluster-id',
name: 'my-cluster-name',
},
resource: {
id: 'orchestratorResourceId',
},
ignored_field_values: {
'cloud.availability_zone': [MORE_THAN_1024_CHARS],
},
cloud: {
provider: ['gcp'],
region: 'us-central-1',
availability_zone: MORE_THAN_1024_CHARS,
project: {
id: 'elastic-project',
},
instance: {
id: 'BgfderflkjTheUiGuy',
},
},
'agent.name': 'node',
},
ignored_field_values: {
'cloud.availability_zone': [MORE_THAN_1024_CHARS],
},
},
dataView
);
dataView
);
const fullHit = buildHit();
const getCustomUnifedDocViewerServices = (params?: {
showApm: boolean;
@ -313,3 +328,18 @@ describe('LogsOverview with APM links', () => {
});
});
});
describe('LogsOverview content breakdown', () => {
it('should render message value', async () => {
const message = 'This is a message';
renderLogsOverview({ hit: buildHit({ message }) });
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(message);
});
it('should render formatted JSON message value', async () => {
const json = { foo: { bar: true } };
const message = JSON.stringify(json);
renderLogsOverview({ hit: buildHit({ message }) });
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(JSON.stringify(json, null, 2));
});
});

View file

@ -34,7 +34,12 @@ export const contentLabel = i18n.translate('unifiedDocViewer.docView.logsOvervie
export function LogsOverviewHeader({ doc }: { doc: LogDocumentOverview }) {
const hasLogLevel = Boolean(doc[fieldConstants.LOG_LEVEL_FIELD]);
const hasTimestamp = Boolean(doc[fieldConstants.TIMESTAMP_FIELD]);
const { field, value } = getMessageFieldWithFallbacks(doc);
const { field, value, formattedValue } = getMessageFieldWithFallbacks(doc, {
includeFormattedValue: true,
});
const messageCodeBlockProps = formattedValue
? { language: 'json', children: formattedValue }
: { language: 'txt', dangerouslySetInnerHTML: { __html: value ?? '' } };
const hasBadges = hasTimestamp || hasLogLevel;
const hasMessageField = field && value;
const hasFlyoutHeader = hasMessageField || hasBadges;
@ -80,14 +85,19 @@ export function LogsOverviewHeader({ doc }: { doc: LogDocumentOverview }) {
</EuiText>
<EuiFlexItem grow={false}>{logLevelAndTimestamp}</EuiFlexItem>
</EuiFlexGroup>
<HoverActionPopover value={value} field={field} anchorPosition="downCenter" display="block">
<HoverActionPopover
value={value}
formattedValue={formattedValue}
field={field}
anchorPosition="downCenter"
display="block"
>
<EuiCodeBlock
overflowHeight={100}
paddingSize="s"
isCopyable
language="txt"
fontSize="s"
dangerouslySetInnerHTML={{ __html: value }}
{...messageCodeBlockProps}
/>
</HoverActionPopover>
</EuiFlexGroup>

View file

@ -23,6 +23,7 @@ interface HoverPopoverActionProps {
children: React.ReactChild;
field: string;
value: unknown;
formattedValue?: string;
title?: unknown;
anchorPosition?: PopoverAnchorPosition;
display?: EuiPopoverProps['display'];
@ -33,12 +34,13 @@ export const HoverActionPopover = ({
title,
field,
value,
formattedValue,
anchorPosition = 'upCenter',
display = 'inline-block',
}: HoverPopoverActionProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const leaveTimer = useRef<NodeJS.Timeout | null>(null);
const uiFieldActions = useUIFieldActions({ field, value });
const uiFieldActions = useUIFieldActions({ field, value, formattedValue });
// The timeout hack is required because we are using a Popover which ideally should be used with a mouseclick,
// but we are using it as a Tooltip. Which means we now need to manually handle the open and close

View file

@ -31,7 +31,6 @@ describe('Source Viewer component', () => {
index={'index1'}
dataView={mockDataView}
width={123}
hasLineNumbers={true}
onRefresh={() => {}}
/>
);
@ -48,7 +47,6 @@ describe('Source Viewer component', () => {
index={'index1'}
dataView={mockDataView}
width={123}
hasLineNumbers={true}
onRefresh={() => {}}
/>
);
@ -86,7 +84,6 @@ describe('Source Viewer component', () => {
index={'index1'}
dataView={mockDataView}
width={123}
hasLineNumbers={true}
onRefresh={() => {}}
/>
);
@ -94,5 +91,7 @@ describe('Source Viewer component', () => {
expect(jsonCodeEditor).not.toBe(null);
expect(jsonCodeEditor.props().jsonValue).toContain('_source');
expect(jsonCodeEditor.props().jsonValue).not.toContain('_score');
expect(jsonCodeEditor.props().hasLineNumbers).toBe(true);
expect(jsonCodeEditor.props().enableFindAction).toBe(true);
});
});

View file

@ -27,10 +27,8 @@ interface SourceViewerProps {
index: string | undefined;
dataView: DataView;
textBasedHits?: DataTableRecord[];
hasLineNumbers: boolean;
width?: number;
decreaseAvailableHeightBy?: number;
requestState?: ElasticRequestState;
onRefresh: () => void;
}
@ -41,9 +39,8 @@ export const DocViewerSource = ({
id,
index,
dataView,
width,
hasLineNumbers,
textBasedHits,
width,
decreaseAvailableHeightBy,
onRefresh,
}: SourceViewerProps) => {
@ -132,7 +129,8 @@ export const DocViewerSource = ({
jsonValue={jsonValue}
width={width}
height={editorHeight}
hasLineNumbers={hasLineNumbers}
hasLineNumbers
enableFindAction
onEditorDidMount={(editorNode: monaco.editor.IStandaloneCodeEditor) => setEditor(editorNode)}
/>
);

View file

@ -29,6 +29,7 @@ interface JsonCodeEditorCommonProps {
height?: string | number;
hasLineNumbers?: boolean;
hideCopyButton?: boolean;
enableFindAction?: boolean;
}
export const JsonCodeEditorCommon = ({
@ -38,6 +39,7 @@ export const JsonCodeEditorCommon = ({
hasLineNumbers,
onEditorDidMount,
hideCopyButton,
enableFindAction,
}: JsonCodeEditorCommonProps) => {
if (jsonValue === '') {
return null;
@ -66,6 +68,7 @@ export const JsonCodeEditorCommon = ({
wordWrap: 'on',
wrappingIndent: 'indent',
}}
enableFindAction={enableFindAction}
/>
);
if (hideCopyButton) {

View file

@ -21,7 +21,9 @@ interface WithValueParam {
value: unknown;
}
interface TFieldActionParams extends WithFieldParam, WithValueParam {}
interface TFieldActionParams extends WithFieldParam, WithValueParam {
formattedValue?: string;
}
export interface TFieldAction {
id: string;
@ -66,7 +68,11 @@ export const [FieldActionsProvider, useFieldActionsContext] = createContainer(us
/**
* This is a preset of the UI elements and related actions that can be used to build an action bar anywhere in a DocView
*/
export const useUIFieldActions = ({ field, value }: TFieldActionParams): TFieldAction[] => {
export const useUIFieldActions = ({
field,
value,
formattedValue,
}: TFieldActionParams): TFieldAction[] => {
const actions = useFieldActionsContext();
return useMemo(
@ -99,10 +105,10 @@ export const useUIFieldActions = ({ field, value }: TFieldActionParams): TFieldA
id: 'copyToClipboardAction',
iconType: 'copyClipboard',
label: copyToClipboardLabel,
onClick: () => actions.copyToClipboard(value as string),
onClick: () => actions.copyToClipboard(formattedValue ?? (value as string)),
},
],
[actions, field, value]
[actions, field, formattedValue, value]
);
};

View file

@ -78,7 +78,6 @@ export class UnifiedDocViewerPublicPlugin
id={hit.raw._id ?? hit.id}
dataView={dataView}
textBasedHits={textBasedHits}
hasLineNumbers
decreaseAvailableHeightBy={decreaseAvailableHeightBy}
onRefresh={() => {}}
/>