Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, {
davismcphee marked this conversation as resolved.
Show resolved Hide resolved
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