From 47b18ddf6f2a0041d1a2a8987b17170199117b50 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Wed, 11 Feb 2026 21:07:22 +0200 Subject: [PATCH 1/6] [Lens] Update Lens API to allow consumers to pass custom use message --- .../kbn-lens-common/embeddable/types.ts | 4 ++ .../components/chart/hooks/use_lens_props.ts | 10 +++- .../src/components/chart/index.tsx | 4 ++ .../public/react_embeddable/data_loader.ts | 14 ++++- .../react_embeddable/type_guards.test.ts | 53 +++++++++++++++++++ .../public/react_embeddable/type_guards.ts | 5 ++ .../user_messages/api.test.ts | 42 ++++++++++++++- .../react_embeddable/user_messages/api.ts | 9 +++- 8 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts diff --git a/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts b/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts index f1e57d7bc5343..d7da865c0f6bf 100644 --- a/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts +++ b/src/platform/packages/shared/kbn-lens-common/embeddable/types.ts @@ -193,6 +193,10 @@ export interface LensPublicCallbacks extends LensApiProps { * Let the consumer overwrite embeddable user messages */ onBeforeBadgesRender?: (userMessages: UserMessage[]) => UserMessage[]; + /** + * Optional user messages from the consumer. + */ + userMessages?: UserMessage[]; onAlertRule?: (data: unknown) => void; } diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts index c2d4d54e79255..0e26a810bfb1c 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/hooks/use_lens_props.ts @@ -46,6 +46,7 @@ export type LensProps = Pick< | 'executionContext' | 'onLoad' | 'lastReloadRequestTime' + | 'userMessages' >; export const useLensProps = ({ @@ -58,6 +59,7 @@ export const useLensProps = ({ chartLayers, yBounds, error, + userMessages, }: { title: string; query: string; @@ -66,13 +68,14 @@ export const useLensProps = ({ chartLayers: LensSeriesLayer[]; yBounds?: LensYBoundsConfig; error?: Error; + userMessages?: EmbeddableComponentProps['userMessages']; } & Pick) => { const { euiTheme } = useEuiTheme(); const chartConfigUpdates$ = useRef>(new BehaviorSubject(undefined)); useEffect(() => { chartConfigUpdates$.current.next(void 0); - }, [query, title, chartLayers, yBounds, error]); + }, [query, title, chartLayers, yBounds, error, userMessages]); // creates a stable function that builds the Lens attributes const buildAttributesFn = useLatest(async () => { @@ -100,6 +103,7 @@ export const useLensProps = ({ esqlVariables: fetchParams.esqlVariables, attributes, lastReloadRequestTime: fetchParams.lastReloadRequestTime, + userMessages, }); }, [ @@ -107,6 +111,7 @@ export const useLensProps = ({ fetchParams.relativeTimeRange, fetchParams.lastReloadRequestTime, fetchParams.esqlVariables, + userMessages, ] ); @@ -204,12 +209,14 @@ const getLensProps = ({ attributes, lastReloadRequestTime, esqlVariables, + userMessages, }: { searchSessionId?: string; attributes: LensAttributes; esqlVariables: ESQLControlVariable[] | undefined; timeRange: TimeRange; lastReloadRequestTime?: number; + userMessages?: EmbeddableComponentProps['userMessages']; }): LensProps => ({ id: 'metricsExperienceLensComponent', viewMode: 'view', @@ -222,4 +229,5 @@ const getLensProps = ({ description: 'metrics experience chart data', }, lastReloadRequestTime, + userMessages, }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx index afaed63abdded..bf0c522ab53cf 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/chart/index.tsx @@ -13,6 +13,7 @@ import type { LensSeriesLayer } from '@kbn/lens-embeddable-utils/config_builder' import { useBoolean } from '@kbn/react-hooks'; import React, { useRef } from 'react'; import type { LensYBoundsConfig } from '@kbn/lens-embeddable-utils/config_builder/types'; +import type { EmbeddableComponentProps } from '@kbn/lens-plugin/public'; import { useLensProps } from './hooks/use_lens_props'; import type { LensWrapperProps } from './lens_wrapper'; import { LensWrapper } from './lens_wrapper'; @@ -34,6 +35,7 @@ export type ChartProps = Pick & yBounds?: LensYBoundsConfig; isLoading?: boolean; error?: Error; + userMessages?: EmbeddableComponentProps['userMessages']; }; const LensWrapperMemo = React.memo(LensWrapper); @@ -56,6 +58,7 @@ export const Chart = ({ extraDisabledActions, isLoading = false, error, + userMessages, }: ChartProps) => { const chartRef = useRef(null); const { euiTheme } = useEuiTheme(); @@ -73,6 +76,7 @@ export const Chart = ({ chartLayers, yBounds, error, + userMessages, }); return ( diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts index 831843a3e6679..4948dd1c085fc 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/data_loader.ts @@ -43,7 +43,7 @@ import { getLogError } from './expressions/telemetry'; import { getUsedDataViews } from './expressions/update_data_views'; import { getParentContext, getRenderMode } from './helper'; import { addLog } from './logger'; -import { apiHasLensComponentCallbacks } from './type_guards'; +import { apiHasLensComponentCallbacks, apiHasUserMessages } from './type_guards'; import type { LensEmbeddableStartServices } from './types'; import { buildUserMessagesHelpers } from './user_messages/api'; @@ -105,6 +105,9 @@ export function loadEmbeddableData( ? parentApi : ({} as LensPublicCallbacks); + const getConsumerMessages = () => + apiHasUserMessages(parentApi) ? parentApi.userMessages ?? [] : []; + // Some convenience api for the user messaging const { getUserMessages, @@ -114,7 +117,14 @@ export function loadEmbeddableData( updateWarnings, resetMessages, updateMessages, - } = buildUserMessagesHelpers(api, internalApi, services, onBeforeBadgesRender, metaInfo); + } = buildUserMessagesHelpers( + api, + internalApi, + services, + onBeforeBadgesRender, + metaInfo, + getConsumerMessages + ); const dispatchBlockingErrorIfAny = () => { const blockingErrors = getUserMessages(blockingMessageDisplayLocations, { diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts new file mode 100644 index 0000000000000..1c6608acc7f35 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.test.ts @@ -0,0 +1,53 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UserMessage } from '@kbn/lens-common'; +import { apiHasUserMessages } from './type_guards'; + +function createUserMessage(uniqueId: string): UserMessage { + return { + uniqueId, + severity: 'info', + shortMessage: 'Test message', + longMessage: () => 'Test message', + fixableInEditor: false, + displayLocations: [{ id: 'embeddableBadge' }], + }; +} + +describe('apiHasUserMessages', () => { + it('should return true when api has userMessages property with array', () => { + const messages: UserMessage[] = [createUserMessage('msg-1')]; + const api = { userMessages: messages }; + expect(apiHasUserMessages(api)).toBe(true); + expect(api.userMessages).toBe(messages); + }); + + it('should return true when api has userMessages property set to empty array', () => { + const api = { userMessages: [] }; + expect(apiHasUserMessages(api)).toBe(true); + expect(api.userMessages).toEqual([]); + }); + + it('should return false when api is null', () => { + expect(apiHasUserMessages(null)).toBe(false); + }); + + it('should return false when api is undefined', () => { + expect(apiHasUserMessages(undefined)).toBe(false); + }); + + it('should return false when api does not have userMessages property', () => { + const api = { onLoad: jest.fn(), onBeforeBadgesRender: jest.fn() }; + expect(apiHasUserMessages(api)).toBe(false); + }); + + it('should return false when api is a primitive', () => { + expect(apiHasUserMessages(0)).toBe(false); + expect(apiHasUserMessages('')).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts index 74a21071fa402..1251d9a6ec399 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/type_guards.ts @@ -15,6 +15,7 @@ import type { LensApiCallbacks, LensPublicCallbacks, LensComponentForwardedProps, + UserMessage, } from '@kbn/lens-common'; import type { LensApi } from '@kbn/lens-common-2'; @@ -48,6 +49,10 @@ export function apiHasLensComponentCallbacks(api: unknown): api is LensPublicCal ); } +export function apiHasUserMessages(api: unknown): api is { userMessages?: UserMessage[] } { + return isObject(api) && Object.hasOwn(api, 'userMessages'); +} + export function apiHasLensComponentProps(api: unknown): api is LensComponentForwardedProps { return ( isObject(api) && diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts index 90f365b57edbb..aa0d97d2fc1fe 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts @@ -53,9 +53,11 @@ function buildUserMessagesApi( { visOverrides, dataOverrides, + getConsumerMessages, }: { visOverrides?: { id: string } & Partial; dataOverrides?: { id: string } & Partial; + getConsumerMessages?: () => UserMessage[]; } = { visOverrides: { id: 'lnsXY' }, dataOverrides: { id: 'formBased' }, @@ -86,7 +88,8 @@ function buildUserMessagesApi( internalApi, services, onBeforeBadgesRender, - metaInfo + metaInfo, + getConsumerMessages ); return { api, internalApi, userMessagesApi, onBeforeBadgesRender }; } @@ -293,6 +296,43 @@ describe('User Messages API', () => { userMessagesApi.getUserMessages('embeddableBadge'); expect(onBeforeBadgesRender).toHaveBeenCalled(); }); + + it('should return both consumer and internal messages', () => { + const consumerMessage = createUserMessage(['embeddableBadge'], 'error'); + const getConsumerMessages = jest.fn(() => [consumerMessage]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + const internalMessage = createUserMessage(['embeddableBadge'], 'warning'); + userMessagesApi.addUserMessages([internalMessage]); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(2); + expect(getConsumerMessages).toHaveBeenCalled(); + + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); + expect(result[1]).toEqual(expect.objectContaining({ uniqueId: internalMessage.uniqueId })); + + expect(result.length).toBeGreaterThanOrEqual(2); + }); + + it('should return only consumer messages when no internal messages', () => { + const consumerMessage = createUserMessage(['embeddableBadge'], 'error'); + const getConsumerMessages = jest.fn(() => [consumerMessage]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); + }); }); describe('addUserMessages', () => { diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts index 4233220f9ba0b..1c38df6f13b44 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.ts @@ -97,7 +97,8 @@ export function buildUserMessagesHelpers( internalApi: LensInternalApi, { coreStart, data, visualizationMap, datasourceMap, spaces }: LensEmbeddableStartServices, onBeforeBadgesRender: LensPublicCallbacks['onBeforeBadgesRender'], - metaInfo?: SharingSavedObjectProps + metaInfo?: SharingSavedObjectProps, + getConsumerMessages?: () => UserMessage[] ): { getUserMessages: UserMessagesGetter; addUserMessages: (messages: UserMessage[]) => void; @@ -204,6 +205,12 @@ export function buildUserMessagesHelpers( }) ?? []) ); + const consumerMessages = getConsumerMessages?.() ?? []; + + if (consumerMessages.length) { + userMessages.push(...consumerMessages); + } + return handleMessageOverwriteFromConsumer( filterAndSortUserMessages( userMessages.concat(Object.values(runtimeUserMessages)), From b9862b36d8803252f7b3c60b48141200ce97a531 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Thu, 12 Feb 2026 12:36:18 +0200 Subject: [PATCH 2/6] Add more test --- .../user_messages/api.test.ts | 79 ++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts index aa0d97d2fc1fe..b7bf67e14488d 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/user_messages/api.test.ts @@ -36,10 +36,11 @@ const ALL_LOCATIONS: UserMessagesDisplayLocationId[] = [ function createUserMessage( locations: Array> = ['embeddableBadge'], - severity: UserMessage['severity'] = 'error' + severity: UserMessage['severity'] = 'error', + id?: string ): UserMessage { return { - uniqueId: faker.string.uuid(), + uniqueId: id ?? faker.string.uuid(), severity: severity || 'error', shortMessage: faker.lorem.word(), longMessage: () => faker.lorem.sentence(), @@ -297,15 +298,43 @@ describe('User Messages API', () => { expect(onBeforeBadgesRender).toHaveBeenCalled(); }); - it('should return both consumer and internal messages', () => { - const consumerMessage = createUserMessage(['embeddableBadge'], 'error'); + it('should not add consumer messages when getConsumerMessages returns empty array', () => { + const getConsumerMessages = jest.fn(() => []); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(0); + expect(getConsumerMessages).toHaveBeenCalled(); + }); + + it('should treat undefined return from getConsumerMessages as no messages', () => { + const getConsumerMessages = jest.fn(() => undefined as unknown as UserMessage[]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(0); + expect(getConsumerMessages).toHaveBeenCalled(); + }); + + it('should filter consumer and internal messages based on severity', () => { + const consumerMessage = createUserMessage(['embeddableBadge'], 'info'); const getConsumerMessages = jest.fn(() => [consumerMessage]); const { userMessagesApi } = buildUserMessagesApi(undefined, { visOverrides: { id: 'lnsXY' }, dataOverrides: { id: 'formBased' }, getConsumerMessages, }); - const internalMessage = createUserMessage(['embeddableBadge'], 'warning'); + const internalMessage = createUserMessage(['embeddableBadge'], 'error'); userMessagesApi.addUserMessages([internalMessage]); const result = userMessagesApi.getUserMessages('embeddableBadge'); @@ -313,10 +342,8 @@ describe('User Messages API', () => { expect(result).toHaveLength(2); expect(getConsumerMessages).toHaveBeenCalled(); - expect(result[0]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); - expect(result[1]).toEqual(expect.objectContaining({ uniqueId: internalMessage.uniqueId })); - - expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: internalMessage.uniqueId })); + expect(result[1]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); }); it('should return only consumer messages when no internal messages', () => { @@ -330,9 +357,41 @@ describe('User Messages API', () => { const result = userMessagesApi.getUserMessages('embeddableBadge'); - expect(result).toHaveLength(1); expect(result[0]).toEqual(expect.objectContaining({ uniqueId: consumerMessage.uniqueId })); }); + + it('when consumer and internal share the same uniqueId, both appear in the result (no dedupe)', () => { + const sharedId = 'shared-message-id'; + const consumerMessage = createUserMessage(['embeddableBadge'], 'error', sharedId); + const getConsumerMessages = jest.fn(() => [consumerMessage]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + const internalMessage = createUserMessage(['embeddableBadge'], 'warning', sharedId); + userMessagesApi.addUserMessages([internalMessage]); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + expect(result).toHaveLength(2); + }); + + it('should return multiple consumer messages', () => { + const msg1 = createUserMessage(['embeddableBadge'], 'info'); + const msg2 = createUserMessage(['embeddableBadge'], 'warning'); + const getConsumerMessages = jest.fn(() => [msg1, msg2]); + const { userMessagesApi } = buildUserMessagesApi(undefined, { + visOverrides: { id: 'lnsXY' }, + dataOverrides: { id: 'formBased' }, + getConsumerMessages, + }); + + const result = userMessagesApi.getUserMessages('embeddableBadge'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual(expect.objectContaining({ uniqueId: msg2.uniqueId })); + expect(result[1]).toEqual(expect.objectContaining({ uniqueId: msg1.uniqueId })); + }); }); describe('addUserMessages', () => { From 7aae5807f1da646baa7825818ec53d299edb01db Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Fri, 13 Feb 2026 13:46:37 +0200 Subject: [PATCH 3/6] Render legacy histogram with a warning --- .../utils/esql/create_aggregation.test.ts | 9 +++++ .../common/utils/esql/create_aggregation.ts | 11 ++++-- .../src/common/utils/index.ts | 1 + .../src/common/utils/legacy_histogram.test.ts | 39 +++++++++++++++++++ .../src/common/utils/legacy_histogram.ts | 20 ++++++++++ .../src/common/utils/user_messages.ts | 27 +++++++++++++ .../metrics/hooks/use_metric_fields.test.ts | 6 +-- .../metrics/hooks/use_metric_fields.ts | 6 +-- .../metrics_experience_grid_content.tsx | 12 +++++- .../metrics/metrics_grid.test.tsx | 34 ++++++++++++++++ .../observability/metrics/metrics_grid.tsx | 7 ++++ 11 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts create mode 100644 src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts create mode 100644 src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts index caeec9e088df3..f195050bb99a7 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.test.ts @@ -89,6 +89,15 @@ describe('getAggregationTemplate', () => { }); expect(result).toBe('PERCENTILE(??metricName, 95)'); }); + + it('should return PERCENTILE with to_tdigest casting for legacy histogram', () => { + const result = getAggregationTemplate({ + type: ES_FIELD_TYPES.HISTOGRAM, + instrument: 'histogram', + placeholderName: 'metricName', + }); + expect(result).toBe('PERCENTILE(TO_TDIGEST(??metricName), 95)'); + }); }); describe('createTimeBucketAggregation', () => { diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts index 1f8159e5fec14..95a2dad41edfe 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_aggregation.ts @@ -16,6 +16,7 @@ import { } from '@kbn/esql-language'; import { replaceParameters } from '@kbn/esql-composer'; import type { MetricField } from '../../../types'; +import { isLegacyHistogram } from '../legacy_histogram'; type Params = Record; interface AggegationTemplateParams { @@ -80,7 +81,7 @@ export function replaceFunctionParams(functionString: string, params: Params): s * @param instrument - The metric instrument type (e.g., 'counter', 'histogram', 'gauge'). * @param placeholderName - The name of the placeholder to use in the template. * @param customFunction - Optional custom aggregation function to use. - * @returns The ES|QL aggregation function template string. + * @returns The ES|QL aggregation function template string. Legacy histograms (type + instrument both histogram) use PERCENTILE(TO_TDIGEST(...), 95). */ export function getAggregationTemplate({ type, @@ -92,6 +93,10 @@ export function getAggregationTemplate({ return `${customFunction}(??${placeholderName})`; } + if (isLegacyHistogram({ type, instrument })) { + return `PERCENTILE(TO_TDIGEST(??${placeholderName}), 95)`; + } + if (type === 'exponential_histogram' || type === 'tdigest') { return `PERCENTILE(??${placeholderName}, 95)`; } @@ -106,8 +111,8 @@ export function getAggregationTemplate({ /** * Creates the metric aggregation part of an ES|QL query. * It returns: - * - For `histogram` instrument: - * - `PERCENTILE(..., 95)` if type is `exponential_histogram or tdigest` + * - For legacy histogram (field type + instrument both histogram): `PERCENTILE(TO_TDIGEST(...), 95)` + * - For `histogram` instrument: `PERCENTILE(..., 95)` if type is `exponential_histogram` or `tdigest` * - `SUM(RATE(...))` for counter instruments * - `AVG(...)` for other metric types * diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts index 58b41a85d16e7..49a8b27dd1bab 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/index.ts @@ -13,3 +13,4 @@ export * from './metric_unit/get_lens_metric_format'; export * from './metric_unit/get_unit_label'; export * from './metric_unit/normalize_unit'; export * from './fields'; +export * from './user_messages'; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts new file mode 100644 index 0000000000000..e644139a4c5e4 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { ES_FIELD_TYPES } from '@kbn/field-types'; +import { isLegacyHistogram } from './legacy_histogram'; + +describe('isLegacyHistogram', () => { + it('returns true when type and instrument are both histogram', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: 'histogram' })).toBe( + true + ); + }); + + it('returns false when type is histogram but instrument is not', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: 'gauge' })).toBe(false); + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: 'counter' })).toBe( + false + ); + }); + + it('returns false when type is histogram and instrument is undefined', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.HISTOGRAM, instrument: undefined })).toBe( + false + ); + }); + + it('returns false when type is not histogram', () => { + expect(isLegacyHistogram({ type: ES_FIELD_TYPES.LONG, instrument: 'histogram' })).toBe(false); + expect( + isLegacyHistogram({ type: ES_FIELD_TYPES.EXPONENTIAL_HISTOGRAM, instrument: 'histogram' }) + ).toBe(false); + }); +}); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts new file mode 100644 index 0000000000000..fae80c4a53b5e --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts @@ -0,0 +1,20 @@ +/* + * 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 type { ES_FIELD_TYPES } from '@kbn/field-types'; +import type { MappingTimeSeriesMetricType } from '@elastic/elasticsearch/lib/api/types'; + +/** + * A legacy histogram is a metric where both the ES field type and the + * metric instrument are histogram. + */ +export const isLegacyHistogram = (field: { + type: ES_FIELD_TYPES; + instrument?: MappingTimeSeriesMetricType; +}): boolean => field.type === 'histogram' && field.instrument === 'histogram'; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts new file mode 100644 index 0000000000000..c66821a927925 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts @@ -0,0 +1,27 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { UserMessage } from '@kbn/lens-plugin/public'; + +export const LEGACY_HISTOGRAM_USER_MESSAGES: UserMessage[] = [ + { + uniqueId: 'metrics-experience-histogram-warning', + severity: 'warning', + shortMessage: i18n.translate('metricsExperience.userMessage.histogram.short', { + defaultMessage: 'Histogram warning', + }), + longMessage: i18n.translate('metricsExperience.userMessage.histogram.long', { + defaultMessage: + 'Calculated assuming T-Digest encoding. If the histogram was encoded differently, the value is approximate', + }), + fixableInEditor: false, + displayLocations: [{ id: 'embeddableBadge' }], + }, +]; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts index 4e561b28ad720..db361c2ff2977 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.test.ts @@ -164,7 +164,7 @@ describe('useMetricFields', () => { expect(cpuField?.dimensions).toEqual([{ name: 'host.name', type: ES_FIELD_TYPES.KEYWORD }]); }); - it('filters out legacy histogram metric types', () => { + it('returns legacy histogram metric types', () => { const sampleRows = new Map([ ['cpu.usage', { 'cpu.usage': 0.75 }], ['http.request.duration', { 'http.request.duration': 150 }], @@ -189,10 +189,10 @@ describe('useMetricFields', () => { const { result } = renderHook(() => useMetricFields()); - expect(result.current.allMetricFields).toHaveLength(2); + expect(result.current.allMetricFields).toHaveLength(3); expect( result.current.allMetricFields.find((f) => f.name === 'http.request.duration') - ).toBeUndefined(); + ).toBeDefined(); expect(result.current.allMetricFields.find((f) => f.name === 'cpu.usage')).toBeDefined(); expect(result.current.allMetricFields.find((f) => f.name === 'memory.used')).toBeDefined(); }); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts index f577d2b33a534..6a7c3fafe7453 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts @@ -13,6 +13,7 @@ import type { Dimension, MetricField, MetricUnit } from '../../../../types'; import { useMetricsExperienceFieldsContext } from '../context/metrics_experience_fields_provider'; import { normalizeUnit } from '../../../../common/utils/metric_unit/normalize_unit'; import { hasValue } from '../../../../common/utils/fields'; +import { isLegacyHistogram } from '../../../../common/utils/legacy_histogram'; import { useMetricsExperienceState } from '../context/metrics_experience_state_provider'; import { useMetricFieldsFilter } from './use_metric_fields_filter'; @@ -49,11 +50,6 @@ export const useMetricFields = (): UseMetricFieldsReturn => { const fields: MetricField[] = []; for (const metricField of metricFields) { - // Filter out legacy histogram metric types - if (metricField.type === 'histogram') { - continue; - } - const row = getSampleRow(metricField.name); if (row) { const enriched = enrichMetricField(metricField, dimensions, row); diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx index 6103889dc370d..57f0da600f246 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid_content.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { @@ -19,9 +19,10 @@ import { useEuiTheme, type EuiFlexGridProps, } from '@elastic/eui'; - import type { MetricField, UnifiedMetricsGridProps } from '../../../types'; import { PAGE_SIZE } from '../../../common/constants'; +import { isLegacyHistogram } from '../../../common/utils/legacy_histogram'; +import { LEGACY_HISTOGRAM_USER_MESSAGES } from '../../../common/utils/user_messages'; import { MetricsGrid } from './metrics_grid'; import { Pagination } from '../../pagination'; import { usePagination } from './hooks'; @@ -71,6 +72,12 @@ export const MetricsExperienceGridContent = ({ [filteredFieldsCount] ); + const getUserMessages = useCallback( + (metric: MetricField) => + isLegacyHistogram(metric) ? LEGACY_HISTOGRAM_USER_MESSAGES : undefined, + [] + ); + return ( diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx index 7c82b683e5a01..ead0207dd1b71 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.test.tsx @@ -127,6 +127,40 @@ describe('MetricsGrid', () => { expect(Chart).toHaveBeenCalledWith(expect.objectContaining({ size: 's' }), expect.anything()); }); + it('passes getUserMessages(metric) result to each chart when getUserMessages is provided', () => { + const messagesForCpu = [ + { + uniqueId: 'cpu-message', + severity: 'warning' as const, + shortMessage: 'CPU', + longMessage: 'CPU message', + fixableInEditor: false, + displayLocations: [{ id: 'embeddableBadge' as const }], + }, + ]; + + const getUserMessages = jest.fn((metric: (typeof fields)[0]) => + metric.name === 'system.cpu.utilization' ? messagesForCpu : undefined + ); + + renderMetricsGrid({ getUserMessages }); + + expect(getUserMessages).toHaveBeenCalledTimes(fields.length); + expect(getUserMessages).toHaveBeenNthCalledWith(1, fields[0]); + expect(getUserMessages).toHaveBeenNthCalledWith(2, fields[1]); + + expect(Chart).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ userMessages: messagesForCpu }), + expect.anything() + ); + expect(Chart).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ userMessages: undefined }), + expect.anything() + ); + }); + it('handles multiple dimensions correctly in ESQL query and chart layers', () => { const multipleDimensions = [ { name: 'host.name', type: ES_FIELD_TYPES.KEYWORD }, diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx index 084df28aeedd1..09e82eb9ac53e 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_grid.tsx @@ -12,6 +12,7 @@ import type { EuiFlexGridProps } from '@elastic/eui'; import { EuiFlexGrid, EuiFlexItem, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; +import type { EmbeddableComponentProps } from '@kbn/lens-plugin/public'; import type { MetricField, Dimension, UnifiedMetricsGridProps } from '../../../types'; import type { ChartSize } from '../../chart'; import { Chart } from '../../chart'; @@ -33,6 +34,7 @@ export type MetricsGridProps = Pick< discoverFetch$: UnifiedMetricsGridProps['fetch$']; fields: MetricField[]; whereStatements?: string[]; + getUserMessages?: (metric: MetricField) => EmbeddableComponentProps['userMessages']; }; const getItemKey = (metric: MetricField, index: number) => { @@ -50,6 +52,7 @@ export const MetricsGrid = ({ fetchParams, discoverFetch$, searchTerm, + getUserMessages, }: MetricsGridProps) => { const gridRef = useRef(null); const { euiTheme } = useEuiTheme(); @@ -149,6 +152,7 @@ export const MetricsGrid = ({ onViewDetails={handleViewDetails} searchTerm={searchTerm} whereStatements={whereStatements} + userMessages={getUserMessages ? getUserMessages(metric) : undefined} /> ); @@ -184,6 +188,7 @@ interface ChartItemProps onFocusCell: (rowIndex: number, colIndex: number) => void; onViewDetails: (index: number, esqlQuery: string, metric: MetricField) => void; whereStatements?: string[]; + userMessages?: EmbeddableComponentProps['userMessages']; } const ChartItem = React.memo( @@ -206,6 +211,7 @@ const ChartItem = React.memo( whereStatements, onFocusCell, onViewDetails, + userMessages, }: ChartItemProps) => { const { euiTheme } = useEuiTheme(); const colorPalette = useMemo( @@ -254,6 +260,7 @@ const ChartItem = React.memo( chartLayers={chartLayers} titleHighlight={searchTerm} extraDisabledActions={[ACTION_OPEN_IN_DISCOVER]} + userMessages={userMessages} /> ); From 0759a8a04d25a575dcf14f9a00be35de25c21ae7 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Fri, 20 Feb 2026 15:04:32 +0200 Subject: [PATCH 4/6] Fix lint --- .../src/common/utils/legacy_histogram.ts | 2 +- .../components/observability/metrics/hooks/use_metric_fields.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts index fae80c4a53b5e..eacf9ed635f3d 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts @@ -17,4 +17,4 @@ import type { MappingTimeSeriesMetricType } from '@elastic/elasticsearch/lib/api export const isLegacyHistogram = (field: { type: ES_FIELD_TYPES; instrument?: MappingTimeSeriesMetricType; -}): boolean => field.type === 'histogram' && field.instrument === 'histogram'; +}): boolean => field.type === ES_FIELD_TYPES.HISTOGRAM && field.instrument === 'histogram'; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts index 6a7c3fafe7453..63fcc57fe5fd4 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_metric_fields.ts @@ -13,7 +13,6 @@ import type { Dimension, MetricField, MetricUnit } from '../../../../types'; import { useMetricsExperienceFieldsContext } from '../context/metrics_experience_fields_provider'; import { normalizeUnit } from '../../../../common/utils/metric_unit/normalize_unit'; import { hasValue } from '../../../../common/utils/fields'; -import { isLegacyHistogram } from '../../../../common/utils/legacy_histogram'; import { useMetricsExperienceState } from '../context/metrics_experience_state_provider'; import { useMetricFieldsFilter } from './use_metric_fields_filter'; From 316e50e1b9a6c848181e550e86fe0493b722a56a Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Fri, 20 Feb 2026 15:26:43 +0200 Subject: [PATCH 5/6] Update legacy histogram utility and wording --- .../src/common/utils/legacy_histogram.ts | 2 +- .../src/common/utils/user_messages.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts index eacf9ed635f3d..fae80c4a53b5e 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/legacy_histogram.ts @@ -17,4 +17,4 @@ import type { MappingTimeSeriesMetricType } from '@elastic/elasticsearch/lib/api export const isLegacyHistogram = (field: { type: ES_FIELD_TYPES; instrument?: MappingTimeSeriesMetricType; -}): boolean => field.type === ES_FIELD_TYPES.HISTOGRAM && field.instrument === 'histogram'; +}): boolean => field.type === 'histogram' && field.instrument === 'histogram'; diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts index c66821a927925..34a2fdf760828 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/user_messages.ts @@ -19,7 +19,7 @@ export const LEGACY_HISTOGRAM_USER_MESSAGES: UserMessage[] = [ }), longMessage: i18n.translate('metricsExperience.userMessage.histogram.long', { defaultMessage: - 'Calculated assuming T-Digest encoding. If the histogram was encoded differently, the value is approximate', + 'Calculated assuming T-Digest encoding. If the histogram was encoded differently, the data is approximate', }), fixableInEditor: false, displayLocations: [{ id: 'embeddableBadge' }], From 326b36cd5ec3357251971f379dbf01ccfd29de7e Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Fri, 20 Feb 2026 18:03:51 +0200 Subject: [PATCH 6/6] Add tests for legacy histogram metric in ESQL query generation --- .../utils/esql/create_esql_query.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts index 5f6798866ac38..5ce548eba6802 100644 --- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts +++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/common/utils/esql/create_esql_query.test.ts @@ -43,6 +43,13 @@ const mockExponentialHistogramMetric: MetricField = { instrument: 'histogram', }; +const mockLegacyHistogramMetric: MetricField = { + ...mockMetric, + name: 'histogram.legacy', + type: ES_FIELD_TYPES.HISTOGRAM, + instrument: 'histogram', +}; + describe('createESQLQuery', () => { it('should generate a basic AVG query for a metric field', () => { const query = createESQLQuery({ metric: mockMetric }); @@ -90,6 +97,31 @@ TS metrics-* ); }); + it('should generate a PERCENTILE query for legacy histogram', () => { + const query = createESQLQuery({ + metric: mockLegacyHistogramMetric, + }); + expect(query).toBe( + ` +TS metrics-* + | STATS PERCENTILE(TO_TDIGEST(histogram.legacy), 95) BY BUCKET(@timestamp, 100, ?_tstart, ?_tend) +`.trim() + ); + }); + + it('should generate a PERCENTILE query for legacy histogram with multiple dimensions', () => { + const query = createESQLQuery({ + metric: mockLegacyHistogramMetric, + splitAccessors: ['service.name', 'host.name'], + }); + expect(query).toBe( + ` +TS metrics-* + | STATS PERCENTILE(TO_TDIGEST(histogram.legacy), 95) BY BUCKET(@timestamp, 100, ?_tstart, ?_tend), \`service.name\`, \`host.name\` +`.trim() + ); + }); + it('should generate exponential histogram query with single dimension', () => { const query = createESQLQuery({ metric: mockExponentialHistogramMetric,