Skip to content

Commit

Permalink
[Discover] Format JSON messages in Observability Logs profile (#205666)
Browse files Browse the repository at this point in the history
## 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)
  • Loading branch information
davismcphee authored Jan 10, 2025
1 parent d96168c commit 518e0af
Show file tree
Hide file tree
Showing 11 changed files with 190 additions and 90 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,59 @@
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';
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> = {}) =>
Expand Down Expand Up @@ -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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,11 +147,9 @@ const SummaryCellPopover = (props: AllSummaryColumnProps) => {
overflowHeight={100}
paddingSize="s"
isCopyable
language="txt"
fontSize="s"
>
{value}
</EuiCodeBlock>
{...messageCodeBlockProps}
/>
</EuiFlexGroup>
)}
{shouldRenderSource && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,35 @@
* 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,
fieldConstants.EVENT_ORIGINAL_FIELD,
] 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 };
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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,
},
'service.name': DATASET_NAME,
'host.name': 'gke-edge-oblt-pool',
'trace.id': 'abcdef',
orchestrator: {
cluster: {
id: 'my-cluster-id',
name: 'my-cluster-name',
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,
},
resource: {
id: 'orchestratorResourceId',
},
},
cloud: {
provider: ['gcp'],
region: 'us-central-1',
availability_zone: MORE_THAN_1024_CHARS,
project: {
id: 'elastic-project',
'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',
},
},
instance: {
id: 'BgfderflkjTheUiGuy',
cloud: {
provider: ['gcp'],
region: 'us-central-1',
availability_zone: MORE_THAN_1024_CHARS,
project: {
id: 'elastic-project',
},
instance: {
id: 'BgfderflkjTheUiGuy',
},
},
'agent.name': 'node',
...fields,
},
ignored_field_values: {
'cloud.availability_zone': [MORE_THAN_1024_CHARS],
},
'agent.name': 'node',
},
ignored_field_values: {
'cloud.availability_zone': [MORE_THAN_1024_CHARS],
},
},
dataView
);
dataView
);

const fullHit = buildHit();

const getCustomUnifedDocViewerServices = (params?: {
showApm: boolean;
Expand Down Expand Up @@ -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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface HoverPopoverActionProps {
children: React.ReactChild;
field: string;
value: unknown;
formattedValue?: string;
title?: unknown;
anchorPosition?: PopoverAnchorPosition;
display?: EuiPopoverProps['display'];
Expand All @@ -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
Expand Down
Loading

0 comments on commit 518e0af

Please sign in to comment.