diff --git a/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/content.tsx b/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/content.tsx
index 1b39b8314bc23..98cfd012f827e 100644
--- a/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/content.tsx
+++ b/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/content.tsx
@@ -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';
@@ -100,6 +100,7 @@ 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();
@@ -107,9 +108,14 @@ export const Content = ({
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;
diff --git a/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/summary_column.tsx b/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/summary_column.tsx
index 02401aeb7c05c..b35772f96d708 100644
--- a/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/summary_column.tsx
+++ b/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/summary_column/summary_column.tsx
@@ -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 {
@@ -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);
diff --git a/src/platform/packages/shared/kbn-discover-utils/index.ts b/src/platform/packages/shared/kbn-discover-utils/index.ts
index 05e3b8f6a1a5a..31d38a7c88124 100644
--- a/src/platform/packages/shared/kbn-discover-utils/index.ts
+++ b/src/platform/packages/shared/kbn-discover-utils/index.ts
@@ -75,6 +75,7 @@ export {
getEsQuerySort,
getTieBreakerFieldName,
escapeAndPreserveHighlightTags,
+ getHighlightedFieldValue,
severityOrder,
} from './src';
diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/escape_preserve_highlight_tags.test.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/escape_preserve_highlight_tags.test.ts
deleted file mode 100644
index ce6812ad357e1..0000000000000
--- a/src/platform/packages/shared/kbn-discover-utils/src/utils/escape_preserve_highlight_tags.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * 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 } from './escape_preserve_highlight_tags';
-
-// Must match the html tags defined in @kbn/field-formats-plugin (html_tags.ts)
-const PRE = '';
-const POST = '';
-
-describe('escapeAndPreserveHighlightTags', () => {
- it('escapes HTML when there are no highlight tags', () => {
- expect(escapeAndPreserveHighlightTags('world')).toBe(
- '<hello>world</hello>'
- );
- });
-
- it('preserves highlight wrappers while escaping the content', () => {
- expect(escapeAndPreserveHighlightTags(`${PRE}${POST}`)).toBe(
- `${PRE}<hello>${POST}`
- );
- });
-
- it('returns only escaped text when there are multiple highlight regions', () => {
- expect(escapeAndPreserveHighlightTags(`${PRE}hello${POST} + ${PRE}world${POST}`)).toBe(
- 'hello + world'
- );
- });
-
- it('escapes plain tags that do not match the highlight format', () => {
- expect(escapeAndPreserveHighlightTags('')).toBe(
- '<mark><hello>'
- );
- });
-});
diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/escape_preserve_highlight_tags.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/escape_preserve_highlight_tags.ts
deleted file mode 100644
index 7187da27bd38d..0000000000000
--- a/src/platform/packages/shared/kbn-discover-utils/src/utils/escape_preserve_highlight_tags.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * 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: These constants are duplicated from @kbn/field-formats-plugin (html_tags.ts).
-// They are kept locally because packages cannot depend on plugins. This is a temporary
-// workaround until we reach an agreement on how to handle formatted/highlighted content
-// across packages and plugins.
-const HIGHLIGHT_PRE_TAG = '';
-const HIGHLIGHT_POST_TAG = '';
-const HIGHLIGHT_TAGS_REGEX = new RegExp(`${HIGHLIGHT_PRE_TAG}|${HIGHLIGHT_POST_TAG}`, 'g');
-
-export function escapeAndPreserveHighlightTags(value: string): string {
- const markTags: string[] = [];
- const cleanText = value.replace(HIGHLIGHT_TAGS_REGEX, (match) => {
- markTags.push(match);
- return '';
- });
-
- const escapedText = escape(cleanText);
-
- return markTags.length === 2 ? `${markTags[0]}${escapedText}${markTags[1]}` : escapedText;
-}
diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/highlight_utils.test.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/highlight_utils.test.ts
new file mode 100644
index 0000000000000..5032abc067463
--- /dev/null
+++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/highlight_utils.test.ts
@@ -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 = '';
+const POST = '';
+
+// 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('world')).toBe(
+ '<hello>world</hello>'
+ );
+ });
+
+ it('preserves highlight wrappers while escaping the content', () => {
+ expect(escapeAndPreserveHighlightTags(`${PRE}${POST}`)).toBe(
+ `${PRE}<hello>${POST}`
+ );
+ });
+
+ it('preserves multiple highlight regions', () => {
+ expect(escapeAndPreserveHighlightTags(`${PRE}hello${POST} + ${PRE}world${POST}`)).toBe(
+ `${PRE}hello${POST} + ${PRE}world${POST}`
+ );
+ });
+
+ it('escapes plain tags that do not match the highlight format', () => {
+ expect(escapeAndPreserveHighlightTags('')).toBe(
+ '<mark><hello></mark>'
+ );
+ });
+
+ 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('', undefined)).toBe('<hello>');
+ });
+
+ it('escapes the field value when highlights is an empty array', () => {
+ expect(getHighlightedFieldValue('', [])).toBe('<hello>');
+ });
+
+ 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('test', [`${ES_PRE}test${ES_POST}`])).toBe(
+ `${PRE}<b>test</b>${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'
+ );
+ });
+});
diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/highlight_utils.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/highlight_utils.ts
new file mode 100644
index 0000000000000..8e57b08527726
--- /dev/null
+++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/highlight_utils.ts
@@ -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 = '';
+const HTML_HIGHLIGHT_POST_TAG = '';
+
+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 tags.
+ * Used for values already processed by formatFieldValue / getHighlightHtml (e.g. resource badges).
+ */
+export function escapeAndPreserveHighlightTags(value: string): string {
+ 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 tags.
+ * Replicates the logic of getHighlightHtml from @kbn/field-formats-plugin, which packages
+ * cannot import directly.
+ *
+ * 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 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;
+}
diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts
index dc41a53cb87cb..cf4988a659b3b 100644
--- a/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts
+++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/index.ts
@@ -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';
diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/content_breakdown/content_breakdown.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/content_breakdown/content_breakdown.tsx
index 15bac72c24674..294058760cea4 100644
--- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/content_breakdown/content_breakdown.tsx
+++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/content_breakdown/content_breakdown.tsx
@@ -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';
@@ -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;