Skip to content
Merged
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 @@ -17,7 +17,7 @@ import {
LOG_LEVEL_REGEX,
OTEL_MESSAGE_FIELD,
} from '@kbn/discover-utils';
import { MESSAGE_FIELD, escapeAndPreserveHighlightTags } from '@kbn/discover-utils';
import { MESSAGE_FIELD, getHighlightedFieldValue } from '@kbn/discover-utils';
import type { EuiThemeComputed } from '@elastic/eui';
import { makeHighContrastColor, useEuiTheme } from '@elastic/eui';
import { useKibanaIsDarkMode } from '@kbn/react-kibana-context-theme';
Expand Down Expand Up @@ -100,16 +100,22 @@ export const Content = ({
}: ContentProps) => {
// Use OTel fallback version that returns the actual field name used
const { field, value } = getMessageFieldWithFallbacks(row.flattened);
const highlights = field ? row.raw.highlight?.[field] : undefined;

const { euiTheme } = useEuiTheme();
const isDarkTheme = useKibanaIsDarkMode();

const highlightedValue = useMemo(
() =>
value
? getHighlightedMessage(escapeAndPreserveHighlightTags(value), row, euiTheme, isDarkTheme)
: value,
[value, row, euiTheme, isDarkTheme]
? getHighlightedMessage(
getHighlightedFieldValue(value, highlights),
row,
euiTheme,
isDarkTheme
)
: undefined,
[value, highlights, row, euiTheme, isDarkTheme]
);

const shouldRenderContent = !!field && !!value && !!highlightedValue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
TRACE_FIELDS,
getMessageFieldWithFallbacks,
} from '@kbn/discover-utils';
import { getAvailableTraceFields, escapeAndPreserveHighlightTags } from '@kbn/discover-utils';
import { getAvailableTraceFields, getHighlightedFieldValue } from '@kbn/discover-utils';
import { Resource } from './resource';
import { Content } from './content';
import {
Expand Down Expand Up @@ -166,11 +166,14 @@ export const SummaryCellPopover = (props: AllSummaryColumnProps) => {
const { field, value, formattedValue } = getMessageFieldWithFallbacks(row.flattened, {
includeFormattedValue: true,
});
const highlights = field ? row.raw.highlight?.[field] : undefined;
const messageCodeBlockProps = formattedValue
? { language: 'json', children: formattedValue }
: {
language: 'txt',
dangerouslySetInnerHTML: { __html: escapeAndPreserveHighlightTags(value ?? '') },
dangerouslySetInnerHTML: {
__html: getHighlightedFieldValue(value ?? '', highlights),
},
};
const shouldRenderContent = Boolean(field && value);

Expand Down
1 change: 1 addition & 0 deletions src/platform/packages/shared/kbn-discover-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export {
getEsQuerySort,
getTieBreakerFieldName,
escapeAndPreserveHighlightTags,
getHighlightedFieldValue,
severityOrder,
} from './src';

Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { escapeAndPreserveHighlightTags, getHighlightedFieldValue } from './highlight_utils';

// Must match the html tags defined in @kbn/field-formats-plugin (html_tags.ts)
const PRE = '<mark class="ffSearch__highlight">';
const POST = '</mark>';

// Must match the tags defined in @kbn/field-formats-plugin (highlight_tags.ts)
const ES_PRE = '@kibana-highlighted-field@';
const ES_POST = '@/kibana-highlighted-field@';

describe('escapeAndPreserveHighlightTags', () => {
it('escapes HTML when there are no highlight tags', () => {
expect(escapeAndPreserveHighlightTags('<hello>world</hello>')).toBe(
'&lt;hello&gt;world&lt;/hello&gt;'
);
});

it('preserves highlight wrappers while escaping the content', () => {
expect(escapeAndPreserveHighlightTags(`${PRE}<hello>${POST}`)).toBe(
`${PRE}&lt;hello&gt;${POST}`
);
});

it('preserves multiple highlight regions', () => {
expect(escapeAndPreserveHighlightTags(`${PRE}hello${POST} + ${PRE}world${POST}`)).toBe(
`${PRE}hello${POST} + ${PRE}world${POST}`
);
});

it('escapes plain <mark> tags that do not match the highlight format', () => {
expect(escapeAndPreserveHighlightTags('<mark><hello></mark>')).toBe(
'&lt;mark&gt;&lt;hello&gt;&lt;/mark&gt;'
);
});

it('escapes ES highlight tags as plain text (not converted)', () => {
expect(escapeAndPreserveHighlightTags(`${ES_PRE}test${ES_POST}`)).toBe(
`${ES_PRE}test${ES_POST}`
);
});
});

describe('getHighlightedFieldValue', () => {
it('escapes the field value when no highlights are provided', () => {
expect(getHighlightedFieldValue('<hello>', undefined)).toBe('&lt;hello&gt;');
});

it('escapes the field value when highlights is an empty array', () => {
expect(getHighlightedFieldValue('<hello>', [])).toBe('&lt;hello&gt;');
});

it('replaces matching text with highlighted version from a single snippet', () => {
expect(
getHighlightedFieldValue('This is a test message', [
`This is a ${ES_PRE}test${ES_POST} message`,
])
).toBe(`This is a ${PRE}test${POST} message`);
});

it('handles multiple highlight regions within a single snippet', () => {
expect(
getHighlightedFieldValue('hello world', [`${ES_PRE}hello${ES_POST} ${ES_PRE}world${ES_POST}`])
).toBe(`${PRE}hello${POST} ${PRE}world${POST}`);
});

it('applies highlights from multiple snippets for multi-valued fields', () => {
expect(
getHighlightedFieldValue('error in service A, warning in service B', [
`${ES_PRE}error${ES_POST} in service A`,
`${ES_PRE}warning${ES_POST} in service B`,
])
).toBe(`${PRE}error${POST} in service A, ${PRE}warning${POST} in service B`);
});

it('escapes HTML in the field value while preserving highlight tags', () => {
expect(getHighlightedFieldValue('<b>test</b>', [`${ES_PRE}<b>test</b>${ES_POST}`])).toBe(
`${PRE}&lt;b&gt;test&lt;/b&gt;${POST}`
);
});

it('returns escaped value when highlights do not match field text', () => {
expect(getHighlightedFieldValue('no match here', [`${ES_PRE}other text${ES_POST}`])).toBe(
'no match here'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { escape } from 'lodash';

// TODO: Remove these duplicated utils when we have a proper way to access the highlight tags
// or when we have a proper HTML field formatters
// Related issue for field formatters: https://github.com/elastic/kibana/issues/259286

// Duplicated from @kbn/field-formats-plugin because packages cannot depend on plugins.
const HTML_HIGHLIGHT_PRE_TAG = '<mark class="ffSearch__highlight">';
const HTML_HIGHLIGHT_POST_TAG = '</mark>';

const ES_HIGHLIGHT_PRE_TAG = '@kibana-highlighted-field@';
const ES_HIGHLIGHT_POST_TAG = '@/kibana-highlighted-field@';

/**
* Escapes HTML in a string while preserving field-format highlight <mark> tags.
* Used for values already processed by formatFieldValue / getHighlightHtml (e.g. resource badges).
*/
export function escapeAndPreserveHighlightTags(value: string): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused about the purpose of this function. Values run through an HTML formatter with formatFieldValue should already be HTML escaped since that's it's purpose. Can you help me understand why it's needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

escapeAndPreserveHighlightTags was introduced in
#253210 as a defense measure since the values are rendered via dangerouslySetInnerHTML. My PR didn't introduce it, only updated it to handle multiple highlights (it was previously limited to two mark tags). I don't think any logic changed since then, so I kept that fix wherever it was needed still.

We can work on removing these temporary fixes once #259286 is completed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for explaining. I'm still not sure this function actually does anything tbh, but it doesn't seem to introduce new risk, so I'll leave it up to you folks to decide.

if (!value.includes(HTML_HIGHLIGHT_PRE_TAG)) {
return escape(value);
}

return value
.split(HTML_HIGHLIGHT_PRE_TAG)
.map((segment, index) => {
if (index === 0) return escape(segment);

const postTagIndex = segment.indexOf(HTML_HIGHLIGHT_POST_TAG);
if (postTagIndex === -1) return escape(segment);

const highlighted = segment.substring(0, postTagIndex);
const rest = segment.substring(postTagIndex + HTML_HIGHLIGHT_POST_TAG.length);

return HTML_HIGHLIGHT_PRE_TAG + escape(highlighted) + HTML_HIGHLIGHT_POST_TAG + escape(rest);
})
.join('');
}

/**
* Merges ES highlight snippets into a field value, producing safe HTML with <mark> tags.
* Replicates the logic of getHighlightHtml from @kbn/field-formats-plugin, which packages
* cannot import directly.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to duplicate the field formats logic here? The components which use this should already have access to the field formats plugin. Can we not just use formatFieldValue directly?

Copy link
Copy Markdown
Contributor Author

@tfcmarques tfcmarques Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tried using formatFieldValue directly (I left a quick summary about this in the PR description). The issue is that formatFieldValue needs a DataViewField from dataView.fields.getByName(field) to look up highlights via field.name. For OTel documents, getMessageFieldWithFallbacks can resolve to body.text, which may not exist in the data view. When getByName returns undefined, the string formatter's htmlConvert can't look up hit.highlight[field.name] and silently skips highlighting.

Additionally, this component applies custom log-level coloring via getHighlightedMessage (wrapping ERROR, WARN, etc. in colored spans), which needs to run on the value after highlight processing. formatFieldValue would give the same output format, but the undefined field issue blocks it.

The duplication is minimal (just the core split/join logic from getHighlightHtml) and explicitly documented as a workaround for the package/plugin boundary. Happy to add a TODO to remove these duplicates, linking to the React-based field formatters work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. I think the fact we need to do this points to an issue around the OTel docs logic, but it doesn't seem to add new risk, so I won't block on it. But for clarity, you'll still need to provide a field when React field formatters land, so you'll probably need to fix the OTel docs logic soon regardless.

*
* Each snippet in the highlights array is the full field value with ES highlight tags
* around matched terms. The function iterates over all snippets (handling multi-valued
* fields), strips the ES tags to find the matching text, and replaces those occurrences
* in the escaped field value with properly tagged <mark> elements.
*/
export function getHighlightedFieldValue(
fieldValue: string,
highlights: string[] | undefined
): string {
if (!highlights?.length) {
return escape(fieldValue);
}

let result = escape(fieldValue);

for (const highlight of highlights) {
const escapedHighlight = escape(highlight);

const untaggedHighlight = escapedHighlight
.split(ES_HIGHLIGHT_PRE_TAG)
.join('')
.split(ES_HIGHLIGHT_POST_TAG)
.join('');

const taggedHighlight = escapedHighlight
.split(ES_HIGHLIGHT_PRE_TAG)
.join(HTML_HIGHLIGHT_PRE_TAG)
.split(ES_HIGHLIGHT_POST_TAG)
.join(HTML_HIGHLIGHT_POST_TAG);

result = result.split(untaggedHighlight).join(taggedHighlight);
}

return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export * from './nested_fields';
export * from './get_field_value';
export * from './get_visible_columns';
export * from './convert_value_to_string';
export * from './escape_preserve_highlight_tags';
export * from './highlight_utils';
export * from './sorting';
export { DiscoverFlyouts, dismissAllFlyoutsExceptFor, dismissFlyouts } from './dismiss_flyouts';
export { prepareDataViewForEditing } from './prepare_data_view_for_editing';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import {
getMessageFieldWithFallbacks,
type DataTableRecord,
type LogDocumentOverview,
getHighlightedFieldValue,
} from '@kbn/discover-utils';
import { escapeAndPreserveHighlightTags } from '@kbn/discover-utils';
import type { ObservabilityStreamsFeature } from '@kbn/discover-shared-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/common';
import { Badges } from '../badges/badges';
Expand All @@ -43,12 +43,15 @@ export const ContentBreakdown = ({
});

const rawFieldValue = hit && field ? hit.flattened[field] : undefined;
const highlights = field ? hit.raw.highlight?.[field] : undefined;

const messageCodeBlockProps = formattedValue
? { language: 'json', children: formattedValue }
: {
language: 'txt',
dangerouslySetInnerHTML: { __html: escapeAndPreserveHighlightTags(value ?? '') },
dangerouslySetInnerHTML: {
__html: getHighlightedFieldValue(value ?? '', highlights),
},
};
const hasMessageField = field && value;

Expand Down
Loading