Skip to content

Commit

Permalink
[Discover] Format JSON messages in Log overview doc viewer tab
Browse files Browse the repository at this point in the history
  • Loading branch information
davismcphee committed Jan 7, 2025
1 parent 5409631 commit 25e559b
Show file tree
Hide file tree
Showing 11 changed files with 188 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,16 @@ 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 shouldRenderContent = Boolean(field && value);

const shouldRenderSource = !shouldRenderContent;
Expand Down Expand Up @@ -142,11 +144,11 @@ const SummaryCellPopover = (props: AllSummaryColumnProps) => {
overflowHeight={100}
paddingSize="s"
isCopyable
language="txt"
language={formattedValue ? 'json' : 'txt'}
fontSize="s"
>
{value}
</EuiCodeBlock>
children={formattedValue}
dangerouslySetInnerHTML={formattedValue ? undefined : { __html: value ?? '' }}
/>
</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 @@ -17,6 +17,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 @@ -58,51 +69,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 @@ -306,3 +321,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,9 @@ 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 hasBadges = hasTimestamp || hasLogLevel;
const hasMessageField = field && value;
const hasFlyoutHeader = hasMessageField || hasBadges;
Expand Down Expand Up @@ -80,14 +82,21 @@ 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"
language={formattedValue ? 'json' : 'txt'}
fontSize="s"
dangerouslySetInnerHTML={{ __html: value }}
children={formattedValue}
dangerouslySetInnerHTML={formattedValue ? undefined : { __html: value }}
/>
</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
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ describe('Source Viewer component', () => {
index={'index1'}
dataView={mockDataView}
width={123}
hasLineNumbers={true}
onRefresh={() => {}}
/>
);
Expand All @@ -48,7 +47,6 @@ describe('Source Viewer component', () => {
index={'index1'}
dataView={mockDataView}
width={123}
hasLineNumbers={true}
onRefresh={() => {}}
/>
);
Expand Down Expand Up @@ -86,13 +84,14 @@ describe('Source Viewer component', () => {
index={'index1'}
dataView={mockDataView}
width={123}
hasLineNumbers={true}
onRefresh={() => {}}
/>
);
const jsonCodeEditor = comp.find(JsonCodeEditorCommon);
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);
});
});
Loading

0 comments on commit 25e559b

Please sign in to comment.