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;