diff --git a/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js b/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js index c00e2b7045..611753bf84 100644 --- a/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js +++ b/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js @@ -55,8 +55,22 @@ describe('Metrics Analytics', () => { suppressResizeObserverIssue(); }); + describe('Check data source picker', () => { + it('Index picker should be only available under Otel metric datasource', () => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); + cy.get('[data-test-subj="metricsIndexPicker"]').should('not.exist'); + + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="openTelemetryOption"]').click(); + cy.get('[data-test-subj="metricsIndexPicker"]').should('exist'); + }); + }); + describe('Search for metrics in search bar', () => { it('Search for metrics in search bar from available metrics', () => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); cy.get('[data-test-subj="metricsSearch"]').type('metric', { wait: 50 }); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') @@ -76,6 +90,8 @@ describe('Metrics Analytics', () => { describe('Select and unselect metrics in sidebar', () => { it('Select and unselect metrics in sidebar', () => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[0]) .trigger('mouseover') @@ -84,7 +100,7 @@ describe('Metrics Analytics', () => { .contains(PPL_METRICS_NAMES[1]) .trigger('mouseover') .click(); - cy.wait(50); + cy.wait(delay/2); cy.get('[data-test-subj="metricsListItems_selectedMetrics"]') .contains(PPL_METRICS_NAMES[0]) .should('exist'); @@ -99,7 +115,7 @@ describe('Metrics Analytics', () => { .contains(PPL_METRICS_NAMES[1]) .trigger('mouseover') .click(); - cy.wait(50); + cy.wait(delay/2); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[0]) .trigger('mouseover') @@ -113,57 +129,29 @@ describe('Metrics Analytics', () => { describe('Test Metric Visualizations', () => { beforeEach(() => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[0]) .trigger('mouseover') .click(); }); - it.only('Resize a Metric visualization in edit mode', () => { - cy.get('[data-test-subj="metrics__editView"]') - .contains('Edit view') - .trigger('mouseover') - .click(); - cy.wait(delay); - cy.get('.react-resizable-handle-se') - // .eq(1) - .trigger('mousedown', { which: 1 }) - .trigger('mousemove', { clientX: 2000, clientY: 800 }) - .trigger('mouseup', { force: true }); - cy.wait(delay); - cy.get('[data-test-subj="metrics__saveView"]').trigger('mouseover').click(); - cy.wait(delay * 3); - cy.get('div.react-grid-layout>div').invoke('height').should('match', new RegExp('790')); - cy.wait(delay); - }); - it('Drag and drop a Metric visualization in edit mode', () => { cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[1]) .trigger('mouseover') .click(); - cy.get('[data-test-subj="metrics__editView"]') - .contains('Edit view') - .trigger('mouseover') - .click(); - cy.wait(delay); cy.get('h5') .contains(PPL_METRICS_NAMES[0]) .trigger('mousedown', { which: 1, force: true }) .trigger('mousemove', { clientX: 415, clientY: 500 }) .trigger('mouseup', { force: true }); - cy.wait(delay); - cy.get('[data-test-subj="metrics__saveView"]') - .trigger('mouseover') - .click({ force: true }) - .then(() => { - cy.wait(delay * 3); - cy.get('div.react-grid-layout>div') - .eq(1) - .invoke('attr', 'style') - .should('match', new RegExp('(.*)transform: translate((.*)10px)(.*)')); - cy.wait(delay); - }); + cy.wait(delay * 3); + cy.get('div.react-grid-layout>div') + .eq(1) + .invoke('attr', 'style') + .should('match', new RegExp('(.*)transform: translate((.*)10px)(.*)')); }); it('Change date filter of the Metrics home page', () => { @@ -175,7 +163,6 @@ describe('Metrics Analytics', () => { cy.get('.euiSuperDatePicker__prettyFormat[data-test-subj="superDatePickerShowDatesButton"]') .contains('This year') .should('exist'); - cy.wait(delay); }); it('Saves metrics to an existing panel', () => { @@ -228,7 +215,6 @@ const createCustomMetric = ({ testMetricIndex }) => { delay: 50, }); cy.get('.euiButton__text').contains('Refresh').trigger('mouseover').click(); - cy.wait(delay); suppressResizeObserverIssue(); cy.get('button[id="main-content-vis"]').contains('Visualizations').trigger('mouseover').click(); cy.wait(delay * 2); @@ -239,11 +225,10 @@ const createCustomMetric = ({ testMetricIndex }) => { .focus() .type(PPL_METRICS_NAMES[metricIndex], { force: true }); cy.get('[data-test-subj="eventExplorer__metricSaveName"]').click({ force: true }); - cy.wait(1000); + cy.wait(delay * 10); cy.get('[data-test-subj="eventExplorer__querySaveConfirm"]', { timeout: COMMAND_TIMEOUT_LONG, }).click(); - cy.wait(delay); cy.get('.euiToastHeader__title').contains('successfully').should('exist'); }; diff --git a/.cypress/integration/panels_test/panels.spec.ts b/.cypress/integration/panels_test/panels.spec.ts index 94fc0ac0ca..7943101627 100644 --- a/.cypress/integration/panels_test/panels.spec.ts +++ b/.cypress/integration/panels_test/panels.spec.ts @@ -775,7 +775,6 @@ const createSavedObjectPanel = (newName = TEST_PANEL) => { }; const addVisualizationsToPanel = (panel, additionalVisualizationIds: string[]) => { - console.log('addVisualizationsToPanel', additionalVisualizationIds); const additionalVisualizations = additionalVisualizationIds.map((id, idx) => { return { savedVisualizationId: `observability-visualization:${id}`, @@ -791,7 +790,6 @@ const addVisualizationsToPanel = (panel, additionalVisualizationIds: string[]) = ...panel.attributes.visualizations, ...additionalVisualizations, ]; - console.log(panel.attributes); cy.request({ method: 'PUT', failOnStatusCode: false, diff --git a/.cypress/utils/metrics_constants.js b/.cypress/utils/metrics_constants.js index 63c5c18955..1698f5da55 100644 --- a/.cypress/utils/metrics_constants.js +++ b/.cypress/utils/metrics_constants.js @@ -21,3 +21,9 @@ export const PPL_METRICS = [ export const VIS_TYPE_LINE = 'Time Series'; export const TESTING_PANEL = 'Mock Testing Panels for Metrics'; + +export const OTEL_METRICS_NAMES = [ + '[Otel Metric] ss4o_metrics-otel-dp.duration', + '[Otel Metric] ss4o_metrics-otel-dp.http.client.duration', + '[Otel Metric] ss4o_metrics-otel-dp.rpc.client.duration', +]; diff --git a/common/constants/metrics.ts b/common/constants/metrics.ts index 7e92e6663b..ffef2b8aa1 100644 --- a/common/constants/metrics.ts +++ b/common/constants/metrics.ts @@ -14,6 +14,7 @@ export const PPL_DATASOURCES_REQUEST = // redux export const OBSERVABILITY_CUSTOM_METRIC = 'CUSTOM_METRICS'; +export const OPEN_TELEMETRY = 'OpenTelemetry'; export const REDUX_SLICE_METRICS = 'metrics'; export const resolutionOptions = [ @@ -33,3 +34,21 @@ export const AGGREGATION_OPTIONS = [ { value: 'min', text: 'min()' }, { value: 'max', text: 'max()' }, ]; + +export const DATASOURCE_OPTIONS = [ + { + label: 'Prometheus', + 'data-test-subj': 'prometheusOption', + }, + { + label: 'OpenTelemetry', + 'data-test-subj': 'openTelemetryOption', + }, +]; +export const DATA_PREPPER_INDEX_NAME = 'ss4o_metrics-*-*'; +export const METRICS_ANALYTICS_DATA_PREPPER_INDICES_ROUTE = + '/api/observability/metrics_analytics/data_prepper_indices'; + +// Regex pattens +export const INDEX_DOCUMENT_NAME_PATTERN = /\[Otel Metric\]\s(\S+?-\S+?)\.(\S+)/; +export const SPAN_RESOLUTION_REGEX = /'(\d+)([smhdwMy])'/; diff --git a/common/constants/shared.ts b/common/constants/shared.ts index e6e84b37bd..4ec97d50fe 100644 --- a/common/constants/shared.ts +++ b/common/constants/shared.ts @@ -2,7 +2,6 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import CSS from 'csstype'; // Client route export const PPL_BASE = '/api/ppl'; @@ -77,9 +76,11 @@ export const PPL_PATTERNS_DOCUMENTATION_URL = 'https://github.com/opensearch-project/sql/blob/2.x/docs/user/ppl/cmd/patterns.rst#description'; export const UI_DATE_FORMAT = 'MM/DD/YYYY hh:mm A'; export const PPL_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSSSSS'; +export const OTEL_DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; export const SPAN_REGEX = /span/; export const PROMQL_METRIC_SUBTYPE = 'promqlmetric'; +export const OTEL_METRIC_SUBTYPE = 'openTelemetryMetric'; export const PPL_METRIC_SUBTYPE = 'metric'; export const PPL_SPAN_REGEX = /by\s*span/i; @@ -131,6 +132,7 @@ export enum VIS_CHART_TYPES { Pie = 'pie', HeatMap = 'heatmap', Text = 'text', + Histogram = 'histogram', } export const NUMERICAL_FIELDS = ['short', 'integer', 'long', 'float', 'double']; diff --git a/common/types/explorer.ts b/common/types/explorer.ts index c46a652491..5b1e18304b 100644 --- a/common/types/explorer.ts +++ b/common/types/explorer.ts @@ -6,7 +6,7 @@ import { History } from 'history'; import Plotly from 'plotly.js-dist'; import { QueryManager } from 'common/query_manager'; -import { VIS_CHART_TYPES } from '../../common/constants/shared'; +import { OTEL_METRIC_SUBTYPE, VIS_CHART_TYPES } from '../../common/constants/shared'; import { AGGREGATIONS, AVAILABLE_FIELDS, @@ -174,12 +174,13 @@ export interface SavedVisualization extends SavedObjectAttributes { selected_fields: { text: string; tokens: [] }; selected_timestamp: IField; type: string; - subType?: 'metric' | 'visualization' | typeof PROMQL_METRIC_SUBTYPE; // exists if sub type is metric + subType?: 'metric' | 'visualization'; // exists if sub type is metric user_configs?: string; units_of_measure?: string; application_id?: string; dataSources: string; // list of type SelectedDataSources that is stringified queryLang: string; + metricType?: typeof PROMQL_METRIC_SUBTYPE | typeof OTEL_METRIC_SUBTYPE; // exists if sub type is metric } export interface ExplorerDataType { diff --git a/common/types/metrics.ts b/common/types/metrics.ts index 7cefde98b8..55a9470cae 100644 --- a/common/types/metrics.ts +++ b/common/types/metrics.ts @@ -4,6 +4,7 @@ */ import { VisualizationType } from './custom_panels'; +type MetricTypes = 'savedCustomMetric' | 'prometheusMetric' | 'openTelemetryMetric'; export interface MetricType extends VisualizationType { id: string; @@ -13,10 +14,15 @@ export interface MetricType extends VisualizationType { w: number; h: number; query: { - type: 'savedCustomMetric' | 'prometheusMetric'; + type: MetricTypes; aggregation: string; attributesGroupBy: string[]; catalog: string; availableAttributes?: string[]; }; } + +export interface OptionType { + label: string; + 'data-test-subj': string; +} diff --git a/public/components/application_analytics/helpers/utils.tsx b/public/components/application_analytics/helpers/utils.tsx index 67ac27fb7d..12516e5a58 100644 --- a/public/components/application_analytics/helpers/utils.tsx +++ b/public/components/application_analytics/helpers/utils.tsx @@ -216,7 +216,7 @@ export const calculateAvailability = async ( for (let i = 0; i < savedVisualizationsIds.length; i++) { const visualizationId = savedVisualizationsIds[i]; // Fetches data for visualization - const visData = await fetchVisualizationById(http, visualizationId, (value: string) => + const visData = await fetchVisualizationById(visualizationId, (value: string) => console.error(value) ); diff --git a/public/components/common/query_utils/__tests__/query_utils.test.tsx b/public/components/common/query_utils/__tests__/query_utils.test.tsx index 4119da5604..c93e226b3c 100644 --- a/public/components/common/query_utils/__tests__/query_utils.test.tsx +++ b/public/components/common/query_utils/__tests__/query_utils.test.tsx @@ -108,6 +108,7 @@ describe('Query Utils', () => { span: '1', resolution: 'h', }; + describe('updateCatalogVisualizationQuery', () => { it('should build plain promQL series query', () => { const query = updateCatalogVisualizationQuery(defaultQueryMetaData); @@ -125,12 +126,14 @@ describe('Query Utils', () => { it('should set timestamps and default resolution', () => { const [startDate, endDate] = ['2023-11-11', '2023-12-11']; const [start, end] = [1699660800, 1702252800]; // 2023-11-11 to 2023-12-11 - const query = preprocessMetricQuery({ - metaData: { queryMetaData: defaultQueryMetaData }, + const currentQuery = + "source = test_catalog.query_range('count by(one,two) (metric)', 1699660800, 1702252800, '1d')"; + const expectedQuery = preprocessMetricQuery({ + metaData: { query: currentQuery, queryMetaData: defaultQueryMetaData }, startTime: startDate, endTime: endDate, }); - expect(query).toMatch(new RegExp(`, ${start}, ${end}, '1d'`)); + expect(expectedQuery).toMatch(new RegExp(`, ${start}, ${end}, '1d'`)); }); }); }); diff --git a/public/components/common/query_utils/index.ts b/public/components/common/query_utils/index.ts index eccd6b9ddd..81eb3b3c92 100644 --- a/public/components/common/query_utils/index.ts +++ b/public/components/common/query_utils/index.ts @@ -5,7 +5,7 @@ import dateMath from '@elastic/datemath'; import { Moment } from 'moment-timezone'; -import { isEmpty } from 'lodash'; +import _, { isEmpty } from 'lodash'; import { SearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; import { PPL_DEFAULT_PATTERN_REGEX_FILETER, @@ -18,8 +18,12 @@ import { PPL_INDEX_INSERT_POINT_REGEX, PPL_INDEX_REGEX, PPL_NEWLINE_REGEX, + OTEL_DATE_FORMAT, + OTEL_METRIC_SUBTYPE, + PROMQL_METRIC_SUBTYPE, } from '../../../../common/constants/shared'; import { IExplorerFields, IQuery } from '../../../../common/types/explorer'; +import { SPAN_RESOLUTION_REGEX } from '../../../../common/constants/metrics'; /* * "Query Utils" This file contains different reused functions in operational panels @@ -61,15 +65,24 @@ export const convertDateTime = ( datetime: string, isStart = true, formatted = true, - isMetrics: boolean = false + metricType: string = '' ) => { - let returnTime: undefined | Moment; + let returnTime: Moment = ''; + if (isStart) { returnTime = dateMath.parse(datetime); } else { returnTime = dateMath.parse(datetime, { roundUp: true }); } - if (isMetrics) { + + if (metricType === OTEL_METRIC_SUBTYPE) { + const formattedDate = returnTime!.utc().format(OTEL_DATE_FORMAT); + const milliseconds = returnTime!.millisecond(); + const formattedMilliseconds = String(milliseconds).padEnd(6, '0'); + return `${formattedDate}.${formattedMilliseconds}Z`; + } + + if (metricType === PROMQL_METRIC_SUBTYPE) { const myDate = new Date(returnTime._d); // Your timezone! const epochTime = myDate.getTime() / 1000.0; return Math.round(epochTime); @@ -100,8 +113,8 @@ export const updateCatalogVisualizationQuery = ({ resolution: string; }) => { const attributesGroupString = attributesGroupBy.join(','); - const startEpochTime = convertDateTime(start, true, false, true); - const endEpochTime = convertDateTime(end, false, false, true); + const startEpochTime = convertDateTime(start, true, false, PROMQL_METRIC_SUBTYPE); + const endEpochTime = convertDateTime(end, false, false, PROMQL_METRIC_SUBTYPE); const promQuery = attributesGroupBy.length === 0 ? `${aggregation} (${catalogTableName})` @@ -183,13 +196,14 @@ export const updatePromQLQueryFilters = ( const { connection, metric, aggregation, attributesGroupBy } = parsePromQLIntoKeywords( promQLQuery ); + const promQLPart = buildPromQLFromMetricQuery({ metric, attributesGroupBy: attributesGroupBy.split(','), aggregation, }); - const start = convertDateTime(startTime, true, false, true); - const end = convertDateTime(endTime, false, false, true); + const start = convertDateTime(startTime, true, false, PROMQL_METRIC_SUBTYPE); + const end = convertDateTime(endTime, false, false, PROMQL_METRIC_SUBTYPE); return `source = ${connection}.query_range('${promQLPart}', ${start}, ${end}, '1h')`; }; @@ -205,19 +219,25 @@ export const getIndexPatternFromRawQuery = (query: string): string => { return getPromQLIndex(query) || getPPLIndex(query); }; +function extractSpanAndResolution(query: string) { + if (!query) return; + + const match = query.match(SPAN_RESOLUTION_REGEX); + return match ? { span: parseInt(match[1], 10), resolution: match[2] } : null; +} + export const preprocessMetricQuery = ({ metaData, startTime, endTime }) => { // convert to moment const start = convertDateTime(startTime, true); const end = convertDateTime(endTime, false); - - const resolution = findMinInterval(start, end); + const spanResolution = extractSpanAndResolution(metaData?.query); const visualizationQuery = updateCatalogVisualizationQuery({ ...metaData.queryMetaData, start, end, - span: '1', - resolution, + span: spanResolution?.span || 1, + resolution: spanResolution?.resolution || 'h', }); return visualizationQuery; @@ -246,7 +266,6 @@ export const preprocessQuery = ({ whereClause?: string; }) => { let finalQuery = ''; - if (isEmpty(rawQuery)) return finalQuery; // convert to moment diff --git a/public/components/custom_panels/custom_panel_view.tsx b/public/components/custom_panels/custom_panel_view.tsx index fd1b35d12b..f72dc73ae7 100644 --- a/public/components/custom_panels/custom_panel_view.tsx +++ b/public/components/custom_panels/custom_panel_view.tsx @@ -391,7 +391,6 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { const visualizationId = panelVisualizations[i].savedVisualizationId; // TODO: create route to get list of visualizations in one call const visData: SavedVisualizationType = await fetchVisualizationById( - http, visualizationId, (error: VizContainerError) => setToast(error.errorMessage, 'danger') ); diff --git a/public/components/custom_panels/helpers/utils.tsx b/public/components/custom_panels/helpers/utils.tsx index e06c795df7..d05b785eb7 100644 --- a/public/components/custom_panels/helpers/utils.tsx +++ b/public/components/custom_panels/helpers/utils.tsx @@ -9,8 +9,13 @@ import _, { forEach, isEmpty, min } from 'lodash'; import { Moment } from 'moment-timezone'; import React from 'react'; import { Layout } from 'react-grid-layout'; -import { CoreStart } from '../../../../../../src/core/public'; -import { PPL_INDEX_REGEX, PPL_WHERE_CLAUSE_REGEX } from '../../../../common/constants/shared'; +import { + OBSERVABILITY_BASE, + OTEL_METRIC_SUBTYPE, + PPL_INDEX_REGEX, + PPL_METRIC_SUBTYPE, + PPL_WHERE_CLAUSE_REGEX, +} from '../../../../common/constants/shared'; import { QueryManager } from '../../../../common/query_manager'; import { SavedVisualizationType, @@ -18,7 +23,7 @@ import { VizContainerError, } from '../../../../common/types/custom_panels'; import { SavedVisualization } from '../../../../common/types/explorer'; -import { removeBacktick } from '../../../../common/utils'; +import { removeBacktick, getOSDHttp } from '../../../../common/utils'; import { getVizContainerProps } from '../../../components/visualizations/charts/helpers'; import PPLService from '../../../services/requests/ppl'; import { SavedObjectsActions } from '../../../services/saved_objects/saved_object_client/saved_objects_actions'; @@ -27,6 +32,7 @@ import { getDefaultVisConfig } from '../../event_analytics/utils'; import { Visualization } from '../../visualizations/visualization'; import { MetricType } from '../../../../common/types/metrics'; import { convertDateTime, updateCatalogVisualizationQuery } from '../../common/query_utils'; +import { INDEX_DOCUMENT_NAME_PATTERN } from '../../../../common/constants/metrics'; /* * "Utils" This file contains different reused functions in operational panels @@ -130,7 +136,6 @@ const queryAccumulator = ( // Fetched Saved Visualization By Id export const fetchVisualizationById = async ( - http: CoreStart['http'], savedVisualizationId: string, setIsError: (error: VizContainerError) => void ) => { @@ -264,6 +269,7 @@ const createCatalogVisualizationMetaData = ({ query, type, subType, + metricType, timeField, queryData, }: { @@ -271,6 +277,7 @@ const createCatalogVisualizationMetaData = ({ query: string; type: string; subType: string; + metricType: string; timeField: string; queryData: object; }) => { @@ -280,6 +287,7 @@ const createCatalogVisualizationMetaData = ({ query, type, subType, + metricType, selected_date_range: { start: 'now/y', end: 'now', @@ -301,7 +309,6 @@ const createCatalogVisualizationMetaData = ({ // Creates a catalogVisualization for a runtime catalog based PPL query and runs getQueryResponse export const renderCatalogVisualization = async ({ - http, pplService, catalogSource, startTime, @@ -315,10 +322,8 @@ export const renderCatalogVisualization = async ({ setVisualizationMetaData, setIsLoading, setIsError, - spanResolution, visualization, }: { - http: CoreStart['http']; pplService: PPLService; catalogSource: string; startTime: string; @@ -332,7 +337,6 @@ export const renderCatalogVisualization = async ({ setVisualizationMetaData: React.Dispatch<React.SetStateAction<undefined>>; setIsLoading: React.Dispatch<React.SetStateAction<boolean>>; setIsError: React.Dispatch<React.SetStateAction<VizContainerError>>; - spanResolution?: string; queryMetaData?: MetricType; visualization: SavedVisualizationType; }) => { @@ -341,7 +345,6 @@ export const renderCatalogVisualization = async ({ const visualizationType = 'line'; const visualizationTimeField = '@timestamp'; - const visualizationSubType = visualization.subType; const visualizationQuery = updateCatalogVisualizationQuery({ ...visualization.queryMetaData, @@ -372,6 +375,7 @@ export const renderCatalogVisualization = async ({ query: visualizationQuery, type: visualizationType, subType: visualization.subType, + metricType: visualization.metricType, timeField: visualizationTimeField, queryData, }); @@ -384,6 +388,182 @@ export const renderCatalogVisualization = async ({ setIsLoading(false); }; +const createOtelVisualizationMetaData = ( + documentName: string, + visualizationType: string, + startTime: string, + endTime: string, + queryData: object +) => { + return { + name: documentName, + description: '', + query: '', + type: visualizationType, + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, + selected_date_range: { + start: startTime, + end: endTime, + text: '', + }, + selected_fields: { + text: '', + tokens: [], + }, + userConfigs: { + layout: dynamicLayoutFromQueryData(queryData), + }, + }; +}; + +export const fetchAggregatedBinCount = async ( + minimumBound: string, + maximumBound: string, + startTime: string, + endTime: string, + documentName: string, + selectedOtelIndex: string, + setIsError: React.Dispatch<React.SetStateAction<VizContainerError>>, + setIsLoading: React.Dispatch<React.SetStateAction<boolean>> +) => { + const http = getOSDHttp(); + try { + const response = await http.post(`${OBSERVABILITY_BASE}/metrics/otel/aggregatedBinCount`, { + body: JSON.stringify({ + min: minimumBound, + max: maximumBound, + startTime, + endTime, + documentName, + index: selectedOtelIndex, + }), + }); + return response; + } catch (error) { + const errorMessage = JSON.parse(error.body.message); + setIsError({ + errorMessage: errorMessage.error.reason || 'Issue in fetching bucket count', + errorDetails: errorMessage.error.details, + }); + console.error(error.body); + } finally { + setIsLoading(false); + } +}; + +export const fetchSampleOTDocument = async (selectedOtelIndex: string, documentName: string) => { + const http = getOSDHttp(); + try { + const response = await http.get( + `${OBSERVABILITY_BASE}/metrics/otel/${selectedOtelIndex}/${documentName}` + ); + return response; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const extractIndexAndDocumentName = (metricString: string): [string, string] | null => { + const match = metricString.match(INDEX_DOCUMENT_NAME_PATTERN); + + if (match) { + const index = match[1]; + const documentName = match[2]; + return [index, documentName]; + } else { + return null; + } +}; + +export const renderOpenTelemetryVisualization = async ({ + startTime, + endTime, + setVisualizationTitle, + setVisualizationType, + setVisualizationData, + setVisualizationMetaData, + setIsLoading, + setIsError, + visualization, + setToast, +}: { + startTime: string; + endTime: string; + setVisualizationTitle: React.Dispatch<React.SetStateAction<string>>; + setVisualizationType: React.Dispatch<React.SetStateAction<string>>; + setVisualizationData: React.Dispatch<React.SetStateAction<Plotly.Data[]>>; + setVisualizationMetaData: React.Dispatch<React.SetStateAction<undefined>>; + setIsLoading: React.Dispatch<React.SetStateAction<boolean>>; + setIsError: React.Dispatch<React.SetStateAction<VizContainerError>>; + visualization: any; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; +}) => { + setIsLoading(true); + setIsError({} as VizContainerError); + + const visualizationType = 'bar'; + let index = visualization?.index; + let documentName = visualization?.name; + + if (index === undefined) { + const indexAndDocumentName = extractIndexAndDocumentName(visualization.name); + index = indexAndDocumentName[0]; + documentName = indexAndDocumentName[1]; + if (documentName === undefined) + setToast('Document name is undefined', 'danger', undefined, 'right'); + } + + const fetchSampleDocument = await fetchSampleOTDocument(index, documentName); + const source = fetchSampleDocument.hits[0]._source; + + setVisualizationType(visualizationType); + setVisualizationTitle(source.name); + + const dataBinsPromises = source.buckets.map(async (bucket: any) => { + try { + const formattedStartTime = convertDateTime(startTime, false, false, OTEL_METRIC_SUBTYPE); + const formattedEndTime = convertDateTime(endTime, false, false, OTEL_METRIC_SUBTYPE); + const fetchingAggregatedBinCount = await fetchAggregatedBinCount( + bucket.min.toString(), + bucket.max.toString(), + formattedStartTime, + formattedEndTime, + documentName, + index, + setIsError, + setIsLoading + ); + + return { + xAxis: bucket.min + ' - ' + bucket.max, + 'count()': fetchingAggregatedBinCount?.nested_buckets?.bucket_range?.bucket_count?.value, + }; + } catch (error) { + console.error('Error processing bucket:', error); + return null; + } + }); + const jsonData = await Promise.all(dataBinsPromises); + const formatedJsonData = { jsonData }; + + const visualizationMetaData = createOtelVisualizationMetaData( + documentName, + visualizationType, + startTime, + endTime, + formatedJsonData + ); + setVisualizationData(formatedJsonData); + setVisualizationMetaData(visualizationMetaData); +}; + // Function to store recently used time filters and set start and end time. export const prependRecentlyUsedRange = ( start: ShortDate, @@ -417,6 +597,9 @@ export const parseSavedVisualizations = ( subType: visualization.savedVisualization.hasOwnProperty('subType') ? visualization.savedVisualization.subType : '', + metricType: visualization.savedVisualization.hasOwnProperty('metricType') + ? visualization.savedVisualization.metricType + : '', units_of_measure: visualization.savedVisualization.hasOwnProperty('units_of_measure') ? visualization.savedVisualization.units_of_measure : '', @@ -476,18 +659,18 @@ export const isPPLFilterValid = ( return true; }; -export const processMetricsData = (schema: any, dataConfig: any) => { +export const processMetricsData = (schema: any) => { if (isEmpty(schema)) return {}; if ( schema.length === 3 && schema.every((schemaField) => ['@labels', '@value', '@timestamp'].includes(schemaField.name)) ) { - return prepareMetricsData(schema, dataConfig); + return prepareMetricsData(schema); } return {}; }; -export const prepareMetricsData = (schema: any, dataConfig: any) => { +export const prepareMetricsData = (schema: any) => { const metricBreakdown: any[] = []; const metricSeries: any[] = []; const metricDimension: any[] = []; @@ -507,6 +690,20 @@ export const prepareMetricsData = (schema: any, dataConfig: any) => { }; }; +export const constructOtelMetricsMetaData = () => { + const otelMetricSeries: any[] = []; + const otelMetricDimension: any[] = []; + + otelMetricDimension.push({ name: 'xAxis', label: 'xAxis', customLabel: '' }); + otelMetricSeries.push({ name: '', label: '', aggregation: 'count', customLabel: '' }); + + return { + series: otelMetricSeries, + dimensions: otelMetricDimension, + span: {}, + }; +}; + // Renders visualization in the vizualization container component export const displayVisualization = (metaData: any, data: any, type: string) => { if (metaData === undefined || isEmpty(metaData)) { @@ -536,7 +733,12 @@ export const displayVisualization = (metaData: any, data: any, type: string) => }; // add metric specific overriding - finalDataConfig = { ...finalDataConfig, ...processMetricsData(data.schema, finalDataConfig) }; + finalDataConfig = { ...finalDataConfig, ...processMetricsData(data.schema) }; + + // add otel metric specific overriding + if (metaData?.metricType === OTEL_METRIC_SUBTYPE) { + finalDataConfig = { ...finalDataConfig, ...constructOtelMetricsMetaData() }; + } const mixedUserConfigs = { availabilityConfig: { diff --git a/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx index f7d7de6720..63eab7bcc0 100644 --- a/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx @@ -6,7 +6,7 @@ import _ from 'lodash'; import React, { useEffect, useState } from 'react'; -import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout'; +import { Layout, Responsive, WidthProvider } from 'react-grid-layout'; import useObservable from 'react-use/lib/useObservable'; import { CoreStart } from '../../../../../../../src/core/public'; import PPLService from '../../../../services/requests/ppl'; @@ -85,33 +85,31 @@ export const PanelGrid = (props: PanelGridProps) => { const isLocked = useObservable(chrome.getIsNavDrawerLocked$()); // Reset Size of Visualizations when layout is changed - const layoutChanged = (currLayouts: Layout[], allLayouts: Layouts) => { + const layoutChanged = (currLayouts: Layout[]) => { window.dispatchEvent(new Event('resize')); setPostEditLayout(currLayouts); }; const loadVizComponents = () => { - const gridDataComps = panelVisualizations.map( - (panelVisualization: VisualizationType, index) => ( - <VisualizationContainer - key={panelVisualization.id} - http={http} - editMode={editMode} - visualizationId={panelVisualization.id} - savedVisualizationId={panelVisualization.savedVisualizationId} - pplService={pplService} - fromTime={startTime} - toTime={endTime} - onRefresh={onRefresh} - onEditClick={onEditClick} - cloneVisualization={cloneVisualization} - pplFilterValue={pplFilterValue} - showFlyout={showFlyout} - removeVisualization={removeVisualization} - contextMenuId="visualization" - /> - ) - ); + const gridDataComps = panelVisualizations.map((panelVisualization: VisualizationType) => ( + <VisualizationContainer + key={panelVisualization.id} + http={http} + editMode={editMode} + visualizationId={panelVisualization.id} + savedVisualizationId={panelVisualization.savedVisualizationId} + pplService={pplService} + fromTime={startTime} + toTime={endTime} + onRefresh={onRefresh} + onEditClick={onEditClick} + cloneVisualization={cloneVisualization} + pplFilterValue={pplFilterValue} + showFlyout={showFlyout} + removeVisualization={removeVisualization} + contextMenuId="visualization" + /> + )); setGridData(gridDataComps); }; diff --git a/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx b/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx index f2a1cff644..08b2e0cccb 100644 --- a/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx @@ -5,9 +5,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ import _, { forEach } from 'lodash'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout'; +import { Layout, Responsive, WidthProvider } from 'react-grid-layout'; import useObservable from 'react-use/lib/useObservable'; import { CoreStart } from '../../../../../../../src/core/public'; import { VisualizationContainer } from '../visualization_container'; @@ -82,33 +82,33 @@ export const PanelGridSO = (props: PanelGridProps) => { const isLocked = useObservable(chrome.getIsNavDrawerLocked$()); // Reset Size of Visualizations when layout is changed - const layoutChanged = (currLayouts: Layout[], allLayouts: Layouts) => { + const layoutChanged = (currLayouts: Layout[]) => { window.dispatchEvent(new Event('resize')); setPostEditLayout(currLayouts); }; const loadVizComponents = () => { - const gridDataComps = panelVisualizations.map( - (panelVisualization: VisualizationType, index) => ( - <VisualizationContainer - key={panelVisualization.id} - http={coreRefs.http!} - editMode={editMode} - visualizationId={panelVisualization.id} - savedVisualizationId={panelVisualization.savedVisualizationId} - pplService={coreRefs.pplService!} - fromTime={startTime} - toTime={endTime} - onRefresh={onRefresh} - onEditClick={onEditClick} - cloneVisualization={cloneVisualization} - pplFilterValue={pplFilterValue} - showFlyout={showFlyout} - removeVisualization={removeVisualization} - contextMenuId="visualization" - /> - ) - ); + const gridDataComps = panelVisualizations.map((panelVisualization: VisualizationType) => ( + <VisualizationContainer + key={panelVisualization.id} + http={coreRefs.http!} + editMode={editMode} + visualizationId={panelVisualization.id} + savedVisualizationId={panelVisualization.savedVisualizationId} + pplService={coreRefs.pplService!} + fromTime={startTime} + toTime={endTime} + onRefresh={onRefresh} + onEditClick={onEditClick} + cloneVisualization={cloneVisualization} + pplFilterValue={pplFilterValue} + showFlyout={showFlyout} + removeVisualization={removeVisualization} + contextMenuId="visualization" + metricType={panelVisualization?.metricType || ''} + panelVisualization={panelVisualization} + /> + )); setGridData(gridDataComps); }; diff --git a/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx b/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx index 6cc87f8e02..ca71d20b56 100644 --- a/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx +++ b/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx @@ -32,6 +32,7 @@ import { fetchVisualizationById, renderCatalogVisualization, renderSavedVisualization, + renderOpenTelemetryVisualization, } from '../../helpers/utils'; import './visualization_container.scss'; import { VizContainerError } from '../../../../../common/types/custom_panels'; @@ -40,7 +41,9 @@ import { coreRefs } from '../../../../framework/core_refs'; import { PROMQL_METRIC_SUBTYPE, observabilityMetricsID, + OTEL_METRIC_SUBTYPE, } from '../../../../../common/constants/shared'; +import { useToast } from '../../../common/toast'; /* * Visualization container - This module is a placeholder to add visualizations in react-grid-layout @@ -114,6 +117,7 @@ export const VisualizationContainer = ({ const onActionsMenuClick = () => setIsPopoverOpen((currPopoverOpen) => !currPopoverOpen); const closeActionsMenu = () => setIsPopoverOpen(false); const { http, pplService } = coreRefs; + const { setToast } = useToast(); const [isModalVisible, setIsModalVisible] = useState(false); const [modalContent, setModalContent] = useState(<></>); @@ -180,7 +184,7 @@ export const VisualizationContainer = ({ disabled={editMode} onClick={() => { closeActionsMenu(); - if (visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE) { + if (visualizationMetaData?.metricType === PROMQL_METRIC_SUBTYPE) { window.location.assign(`${observabilityMetricsID}#/${savedVisualizationId}`); } else { onEditClick(savedVisualizationId); @@ -227,7 +231,7 @@ export const VisualizationContainer = ({ ]; if ( - visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE && + visualizationMetaData?.metricType === PROMQL_METRIC_SUBTYPE && actionMenuType === 'metricsGrid' ) { popoverPanel = [showPPLQueryPanel]; @@ -237,7 +241,7 @@ export const VisualizationContainer = ({ const fetchVisualization = async () => { return savedVisualizationId - ? await fetchVisualizationById(http, savedVisualizationId, setIsError) + ? await fetchVisualizationById(savedVisualizationId, setIsError) : inputMetaData; }; @@ -247,10 +251,22 @@ export const VisualizationContainer = ({ if (!visualization && !savedVisualizationId) return; - if (visualization.subType === PROMQL_METRIC_SUBTYPE) { + if (visualization.metricType === OTEL_METRIC_SUBTYPE) + await renderOpenTelemetryVisualization({ + visualization, + startTime: fromTime, + endTime: toTime, + setVisualizationTitle, + setVisualizationType, + setVisualizationData, + setVisualizationMetaData, + setIsLoading, + setIsError, + setToast, + }); + else if (visualization.metricType === PROMQL_METRIC_SUBTYPE) renderCatalogVisualization({ visualization, - http, pplService, catalogSource: visualizationId, startTime: fromTime, @@ -266,7 +282,7 @@ export const VisualizationContainer = ({ setIsError, queryMetaData, }); - } else + else await renderSavedVisualization({ visualization, http, diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index 5ac6b0591a..fcb84df1ea 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -633,7 +633,7 @@ export const Explorer = ({ indexFields: explorerFields, userConfigs: { ...visualizationSettings, - ...processMetricsData(explorerData.schema, visualizationSettings), + ...processMetricsData(explorerData.schema), }, appData: { fromApp: appLogEvents }, explorer: { explorerData, explorerFields, query, http, pplService }, diff --git a/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap b/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap index d8e3f5fd06..b543738ae2 100644 --- a/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap +++ b/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap @@ -93,6 +93,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination save to panel", "objectId": "Kocoln0BYMuJGDsOwDma", "objectType": "savedVisualization", @@ -109,6 +110,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination", "objectId": "KIcoln0BYMuJGDsOhDmk", "objectType": "savedVisualization", @@ -603,6 +605,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination save to panel", "objectId": "Kocoln0BYMuJGDsOwDma", "objectType": "savedVisualization", @@ -619,6 +622,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination", "objectId": "KIcoln0BYMuJGDsOhDmk", "objectType": "savedVisualization", diff --git a/public/components/event_analytics/home/saved_objects_table.tsx b/public/components/event_analytics/home/saved_objects_table.tsx index 4d42a0b071..dc4b01ff02 100644 --- a/public/components/event_analytics/home/saved_objects_table.tsx +++ b/public/components/event_analytics/home/saved_objects_table.tsx @@ -66,7 +66,7 @@ export function SavedQueryTable({ sortable: true, truncateText: true, render: (item: any) => { - return item.subType === PROMQL_METRIC_SUBTYPE ? ( + return item.metricType === PROMQL_METRIC_SUBTYPE ? ( <EuiLink href={`${METRIC_EXPLORER_BASE_PATH}${item.objectId}`} data-test-subj="eventHome__savedQueryTableName" @@ -108,6 +108,7 @@ export function SavedQueryTable({ timestamp: savedObject.selected_timestamp?.name, fields: savedObject.selected_fields?.tokens || [], subType: savedObject.subType, + metricType: savedObject?.metricType || 'customMetric', }; return { id: h.objectId, diff --git a/public/components/event_analytics/utils/utils.tsx b/public/components/event_analytics/utils/utils.tsx index b19f3ce022..2558ee9a6a 100644 --- a/public/components/event_analytics/utils/utils.tsx +++ b/public/components/event_analytics/utils/utils.tsx @@ -19,6 +19,7 @@ import { TIME_INTERVAL_OPTIONS, } from '../../../../common/constants/explorer'; import { + OTEL_METRIC_SUBTYPE, PPL_DATE_FORMAT, PPL_INDEX_INSERT_POINT_REGEX, PPL_INDEX_REGEX, @@ -38,6 +39,13 @@ import { StatsAggregationChunk, statsChunk, } from '../../../../common/query_manager/ast/types'; +import { + extractIndexAndDocumentName, + fetchSampleOTDocument, + fetchAggregatedBinCount, +} from '../../custom_panels/helpers/utils'; +import { convertDateTime } from '../../common/query_utils'; +import { VizContainerError } from '../../../../common/types/custom_panels'; /* Builds Final Query for the surrounding events * -> Final Query is as follows: @@ -267,6 +275,7 @@ export const getMetricVisConfig = (metric) => { [BREAKDOWNS]: [], queryMetaData: metric.queryMetaData, subType: metric.subType, + metricType: metric.metricType, legend: { showLegend: 'hidden' }, // force no-legend in dashboard displays }; }; @@ -301,6 +310,65 @@ export const getDefaultVisConfig = (statsToken: statsChunk) => { }; }; +export const fetchOtelMetric = async ({ + visualizationName, + startTime, + endTime, + setIsError, + setIsLoading, + setToast, +}: { + visualizationName: string; + startTime: string; + endTime: string; + setIsLoading: React.Dispatch<React.SetStateAction<boolean>>; + setIsError: React.Dispatch<React.SetStateAction<VizContainerError>>; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; +}) => { + const indexAndDocumentName = extractIndexAndDocumentName(visualizationName); + const index = indexAndDocumentName[0]; + const documentName = indexAndDocumentName[1]; + if (documentName === undefined) + setToast('Document name is undefined', 'danger', undefined, 'right'); + + const fetchSampleDocument = await fetchSampleOTDocument(index, documentName); + const source = fetchSampleDocument.hits[0]._source; + + const dataBinsPromises = source.buckets.map(async (bucket: any) => { + try { + const formattedStartTime = convertDateTime(startTime, false, false, OTEL_METRIC_SUBTYPE); + const formattedEndTime = convertDateTime(endTime, false, false, OTEL_METRIC_SUBTYPE); + const fetchingAggregatedBinCount = await fetchAggregatedBinCount( + bucket.min.toString(), + bucket.max.toString(), + formattedStartTime, + formattedEndTime, + documentName, + index, + setIsError, + setIsLoading + ); + + return { + xAxis: bucket.min + ' - ' + bucket.max, + 'count()': fetchingAggregatedBinCount?.nested_buckets?.bucket_range?.bucket_count?.value, + }; + } catch (error) { + console.error('Error processing bucket:', error); + return null; + } + }); + const jsonData = await Promise.all(dataBinsPromises); + const formatedJsonData = { jsonData }; + + return formatedJsonData; +}; + const getSpanValue = (groupByToken: GroupByChunk) => { const timeUnitValue = TIME_INTERVAL_OPTIONS.find( (timeUnit) => timeUnit.value === groupByToken?.span?.span_expression.time_unit diff --git a/public/components/metrics/helpers/utils.tsx b/public/components/metrics/helpers/utils.tsx index 4cd1440d5b..e6478cfc97 100644 --- a/public/components/metrics/helpers/utils.tsx +++ b/public/components/metrics/helpers/utils.tsx @@ -8,7 +8,11 @@ import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import React from 'react'; import { Layout } from 'react-grid-layout'; import { VISUALIZATION } from '../../../../common/constants/metrics'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +import { + OTEL_METRIC_SUBTYPE, + PROMQL_METRIC_SUBTYPE, + PPL_METRIC_SUBTYPE, +} from '../../../../common/constants/shared'; import PPLService from '../../../services/requests/ppl'; import { MetricType } from '../../../../common/types/metrics'; import { VisualizationType } from '../../../../common/types/custom_panels'; @@ -74,7 +78,11 @@ export const sortMetricLayout = (metricsLayout: MetricType[]) => { }); }; -export const visualizationFromMetric = (metric, span, resolution): SavedVisualizationType => { +export const visualizationFromPrometheusMetric = ( + metric, + span, + resolution +): SavedVisualizationType => { const userConfigs = JSON.stringify({ dataConfig: { chartStyles: { @@ -95,7 +103,41 @@ export const visualizationFromMetric = (metric, span, resolution): SavedVisualiz resolution, }, type: 'line', - subType: PROMQL_METRIC_SUBTYPE, + subType: PPL_METRIC_SUBTYPE, + metricType: PROMQL_METRIC_SUBTYPE, userConfigs: JSON.stringify(userConfigs), }; }; + +export const createOtelMetric = (metric: any) => { + return { + name: '[Otel Metric] ' + metric.index + '.' + metric.name, + index: metric.index, + documentName: metric.name, + description: '', + query: '', + type: 'bar', + selected_fields: { + text: '', + tokens: [], + }, + sub_type: 'metric', + metric_type: OTEL_METRIC_SUBTYPE, + user_configs: {}, + }; +}; + +export const visualizationFromOtelMetric = (metric: any) => { + return { + query: '', + index: metric.index, + documentName: metric.documentName, + dateRange: ['now-1d', 'now'], + name: '[Otel Metric] ' + metric.index + '.' + metric.name, + description: metric.description, + type: 'bar', + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, + userConfigs: JSON.stringify(metric.user_configs), + }; +}; diff --git a/public/components/metrics/index.tsx b/public/components/metrics/index.tsx index 2e7f7a1cf8..08df0c8e1f 100644 --- a/public/components/metrics/index.tsx +++ b/public/components/metrics/index.tsx @@ -5,12 +5,13 @@ import './index.scss'; import { EuiPage, EuiPageBody, EuiResizableContainer } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { HashRouter, Route, RouteComponentProps, StaticContext } from 'react-router-dom'; import { ChromeBreadcrumb } from '../../../../../src/core/public'; import { Sidebar } from './sidebar/sidebar'; import PPLService from '../../services/requests/ppl'; import { TopMenu } from './top_menu/top_menu'; +import { OptionType } from '../../../common/types/metrics'; import { MetricsGrid } from './view/metrics_grid'; import SavedObjects from '../../services/saved_objects/event_analytics/saved_objects'; @@ -23,6 +24,10 @@ interface MetricsProps { } export const Home = ({ chrome, parentBreadcrumb }: MetricsProps) => { + // Side bar constants + const [selectedDataSource, setSelectedDataSource] = useState<OptionType[]>([]); + const [selectedOTIndex, setSelectedOTIndex] = useState([]); + useEffect(() => { chrome.setBreadcrumbs([ parentBreadcrumb, @@ -49,7 +54,13 @@ export const Home = ({ chrome, parentBreadcrumb }: MetricsProps) => { {(EuiResizablePanel, EuiResizableButton) => ( <> <EuiResizablePanel mode="collapsible" initialSize={20} minSize="10%"> - <Sidebar additionalSelectedMetricId={routerProps.match.params.id} /> + <Sidebar + additionalSelectedMetricId={routerProps.match.params.id} + selectedDataSource={selectedDataSource} + setSelectedDataSource={setSelectedDataSource} + selectedOTIndex={selectedOTIndex} + setSelectedOTIndex={setSelectedOTIndex} + /> </EuiResizablePanel> <EuiResizableButton /> diff --git a/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx b/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx index 17f3681519..80afa4f60f 100644 --- a/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx +++ b/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import type { RenderOptions } from '@testing-library/react'; import '@testing-library/jest-dom'; @@ -11,14 +11,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import { OBSERVABILITY_CUSTOM_METRIC } from '../../../../../../common/constants/metrics'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../../../common/constants/shared'; -import { - metricsReducers, - mergeMetrics, - clearSelectedMetrics, - addSelectedMetric, - metricSlice, -} from '../metrics_slice'; +import { metricsReducers, mergeMetrics, clearSelectedMetrics, metricSlice } from '../metrics_slice'; import { sampleSavedMetric } from '../../../../../../test/metrics_constants'; import httpClientMock from '../../../../../../test/__mocks__/httpClientMock'; import { Sidebar } from '../../../sidebar/sidebar'; @@ -43,7 +36,9 @@ const defaultInitialState = { resolution: 'h', recentlyUsedRanges: [], }, - refresh: 0, // set to new Date() to trigger + refresh: 0, + otelIndices: [], + otelDocumentNames: [], // set to new Date() to trigger }; // This type interface extends the default options for render from RTL, as well @@ -60,6 +55,7 @@ function configureMetricStore(additionalState = {}) { return configureStore({ reducer: { metrics: metricsReducers }, preloadedState }); } +// eslint-disable-next-line jest/no-export export function renderWithMetricsProviders( ui: React.ReactElement, { @@ -69,10 +65,6 @@ export function renderWithMetricsProviders( ...renderOptions }: ExtendedRenderOptions = {} ) { - function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element { - return <Provider store={store}>{children}</Provider>; - } - // Return an object with the store and all of RTL's query functions return { store, ...render(<Provider store={store}>{ui}</Provider>, { ...renderOptions }) }; } @@ -108,6 +100,9 @@ describe('Add and Remove Selected Metrics', () => { ...defaultInitialState, }, }; + const selectedDataSource = [{ label: 'Prometheus', 'data-test-subj': 'prometheusOption' }]; + const setSelectedDataSource = jest.fn(); + const setSelectedOTIndex = jest.fn(); setPPLService(new PPLService(httpClientMock)); @@ -123,7 +118,15 @@ describe('Add and Remove Selected Metrics', () => { }); // Act - renderWithMetricsProviders(<Sidebar />, { preloadedState }); + renderWithMetricsProviders( + <Sidebar + selectedDataSource={selectedDataSource} + setSelectedDataSource={setSelectedDataSource} + selectedOTIndex={''} + setSelectedOTIndex={setSelectedOTIndex} + />, + { preloadedState } + ); // Assert @@ -142,6 +145,7 @@ describe('Add and Remove Selected Metrics', () => { expect(await screen.findByText(/Selected Metrics 1 of 1/)).toBeInTheDocument(); }); }); + describe('Metrics redux state tests', () => { it('Should initially set metrics state', () => { const store = configureMetricStore(); @@ -177,26 +181,6 @@ describe('metricsSlice actions and reducers', () => { expect(newState.selectedIds).toEqual([]); }); - it('should handle updateMetricQuery', () => { - const metricsState = { - ...defaultInitialState, - metrics: { metric1: { name: 'metricName' }, metric2: { name: 'metric2' } }, - }; - // const store = configureStore( { metrics: metricsReducers }, - // preloadedState: { metrics: metricsState }, - // }); - - // const dispatchedAction = store.dispatch( - // updateMetricQuery('metric1', { availableAttributes: ['label1'] }) - // ); - // expect(dispatchedAction.type).toEqual('metrics/setMetric'); - // expect(dispatchedAction.payload).toMatchObject({ - // aggregation: 'avg', - // attributesGroupBy: [], - // availableAttributes: ['label1'], - // }); - }); - describe('loadMetrics', () => { it('should handle setSortedIds', async () => { const store = configureMetricStore(); diff --git a/public/components/metrics/redux/slices/metrics_slice.ts b/public/components/metrics/redux/slices/metrics_slice.ts index f623585c37..acb68479c1 100644 --- a/public/components/metrics/redux/slices/metrics_slice.ts +++ b/public/components/metrics/redux/slices/metrics_slice.ts @@ -17,8 +17,12 @@ import { SavedObjectsActions } from '../../../../services/saved_objects/saved_ob import { ObservabilitySavedVisualization } from '../../../../services/saved_objects/saved_object_client/types'; import { pplServiceRequestor } from '../../helpers/utils'; import { coreRefs } from '../../../../framework/core_refs'; -import { PPL_METRIC_SUBTYPE, PROMQL_METRIC_SUBTYPE } from '../../../../../common/constants/shared'; -import { getPPLService } from '../../../../../common/utils'; +import { + PPL_METRIC_SUBTYPE, + PROMQL_METRIC_SUBTYPE, + OBSERVABILITY_BASE, +} from '../../../../../common/constants/shared'; +import { getOSDHttp, getPPLService } from '../../../../../common/utils'; export interface IconAttributes { color: string; @@ -48,8 +52,8 @@ export interface DateSpanFilter { const initialState = { metrics: {}, - selectedIds: [], - sortedIds: [], + selectedIds: [], // selected IDs + sortedIds: [], // all avaliable metrics search: '', metricsLayout: [], dataSources: [OBSERVABILITY_CUSTOM_METRIC], @@ -63,6 +67,9 @@ const initialState = { recentlyUsedRanges: [], }, refresh: 0, // set to new Date() to trigger + selectedDataSource: '', + otelIndices: [], + otelDocumentNames: [], }; const mergeMetricCustomizer = function (objValue, srcValue) { @@ -87,22 +94,27 @@ export const loadMetrics = () => async (dispatch) => { const customDataRequest = fetchCustomMetrics(); const remoteDataSourcesResponse = await pplServiceRequestor(pplService!, PPL_DATASOURCES_REQUEST); const remoteDataSources = remoteDataSourcesResponse.data.DATASOURCE_NAME; - dispatch(setDataSources(remoteDataSources)); dispatch(setDataSourceTitles(remoteDataSources)); dispatch( - setDataSourceIcons(coloredIconsFrom([OBSERVABILITY_CUSTOM_METRIC, ...remoteDataSources])) + setDataSourceIcons( + coloredIconsFrom([OBSERVABILITY_CUSTOM_METRIC, ...remoteDataSources, 'OpenTelemetry']) + ) ); const remoteDataRequests = await fetchRemoteMetrics(remoteDataSources); const metricsResultSet = await Promise.all([customDataRequest, ...remoteDataRequests]); const metricsResult = metricsResultSet.flat(); - const metricsMapById = keyBy(metricsResult.flat(), 'id'); dispatch(mergeMetrics(metricsMapById)); const sortedIds = sortBy(metricsResult, 'catalog', 'id').map((m) => m.id); - dispatch(setSortedIds(sortedIds)); + await dispatch(setSortedIds(sortedIds)); +}; + +export const loadOTIndices = () => async (dispatch) => { + const fetchOTindices = await fetchOpenTelemetryIndices(); + dispatch(setOtelIndices(fetchOTindices)); }; const fetchCustomMetrics = async () => { @@ -112,7 +124,6 @@ const fetchCustomMetrics = async () => { const savedMetrics = dataSet.observabilityObjectList.filter((obj) => [PROMQL_METRIC_SUBTYPE, PPL_METRIC_SUBTYPE].includes(obj.savedVisualization?.subType) ); - return savedMetrics.map((obj: any) => ({ id: obj.objectId, savedVisualizationId: obj.objectId, @@ -121,6 +132,7 @@ const fetchCustomMetrics = async () => { catalog: OBSERVABILITY_CUSTOM_METRIC, type: obj.savedVisualization.type, subType: obj.savedVisualization.subType, + metricType: 'customMetric', aggregation: obj.savedVisualization.queryMetaData?.aggregation ?? 'avg', availableAttributes: [], attributesGroupBy: obj.savedVisualization.queryMetaData?.attributesGroupBy ?? [], @@ -153,12 +165,31 @@ const fetchRemoteMetrics = (remoteDataSources: string[]) => attributesGroupBy: [], availableAttributes: [], type: 'line', - subType: PROMQL_METRIC_SUBTYPE, + subType: PPL_METRIC_SUBTYPE, + metricType: PROMQL_METRIC_SUBTYPE, recentlyCreated: false, })) ) ); +export const fetchOpenTelemetryIndices = async () => { + const http = getOSDHttp(); + return http + .get(`${OBSERVABILITY_BASE}/search/indices`, { + query: { + format: 'json', + }, + }) + .catch((error) => console.error(error)); +}; + +export const fetchOpenTelemetryDocumentNames = (selectedOtelIndex: string) => async () => { + const http = getOSDHttp(); + return http + .get(`${OBSERVABILITY_BASE}/metrics/otel/${selectedOtelIndex}/documentNames`) + .catch((error) => console.error(error)); +}; + export const metricSlice = createSlice({ name: REDUX_SLICE_METRICS, initialState, @@ -213,6 +244,15 @@ export const metricSlice = createSlice({ setRefresh: (state) => { state.refresh = Date.now(); }, + setSelectedDataSource: (state, { payload }) => { + state.selectedDataSource = payload; + }, + setOtelIndices: (state, { payload }) => { + state.otelIndices = payload; + }, + setOtelDocumentNames: (state, { payload }) => { + state.otelDocumentNames = payload; + }, }, }); @@ -227,13 +267,16 @@ export const { setDataSourceTitles, setDataSourceIcons, updateMetric, + setSelectedDataSource, + setOtelIndices, + setOtelDocumentNames, } = metricSlice.actions; /** private actions */ -const { setMetrics, setMetric, setSortedIds } = metricSlice.actions; +export const { setMetrics, setMetric, setSortedIds } = metricSlice.actions; -const getAvailableAttributes = (id, metricIndex) => async (dispatch, getState) => { +const getAvailableAttributes = (id, metricIndex) => async (dispatch) => { const { toasts } = coreRefs; const pplService = getPPLService(); @@ -257,13 +300,13 @@ export const addSelectedMetric = (metric: MetricType) => async (dispatch, getSta const currentSelectedIds = getState().metrics.selectedIds; if (currentSelectedIds.includes(metric.id)) return; - if (metric.subType === PROMQL_METRIC_SUBTYPE) { + if (metric.metricType === PROMQL_METRIC_SUBTYPE) { await dispatch(getAvailableAttributes(metric.id, metric.index)); } await dispatch(selectMetric(metric)); }; -export const removeSelectedMetric = ({ id }) => async (dispatch, getState) => { +export const removeSelectedMetric = ({ id }) => async (dispatch) => { dispatch(deSelectMetric(id)); }; @@ -271,7 +314,6 @@ export const updateMetricQuery = (id, { availableAttributes, aggregation, attrib dispatch, getState ) => { - const state = getState(); const staticMetric = getState().metrics.metrics[id]; const metric = { ...staticMetric, @@ -321,4 +363,10 @@ export const metricQuerySelector = (id) => (state) => availableAttributes: [], }; +export const selectedDataSourcesSelector = (state) => state.metrics.selectedDataSource; + +export const otelIndexSelector = (state) => state.metrics.otelIndices; + +export const otelDocumentNamesSelector = (state) => state.metrics.otelDocumentNames; + export const metricsReducers = metricSlice.reducer; diff --git a/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap b/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap index a22e4729ce..64f504bf9c 100644 --- a/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap +++ b/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap @@ -12,7 +12,12 @@ exports[`Side Bar Component renders Side Bar Component 1`] = ` } } > - <Sidebar> + <Sidebar + selectedDataSource="" + selectedOTIndex="" + setSelectedDataSource={[MockFunction]} + setSelectedOTIndex={[MockFunction]} + > <I18nProvider> <IntlProvider defaultLocale="en" @@ -102,6 +107,260 @@ exports[`Side Bar Component renders Side Bar Component 1`] = ` <div id="sidebar" > + <DataSourcePicker + selectedDataSource="" + setSelectedDataSource={[MockFunction]} + > + <div + className="metrics-data-source-picker" + > + <EuiTitle + size="xxxs" + > + <h5 + className="euiTitle euiTitle--xxxsmall" + > + Data source + </h5> + </EuiTitle> + <EuiComboBox + async={false} + compressed={false} + data-test-subj="metricsDataSourcePicker" + fullWidth={false} + isClearable={true} + onChange={[Function]} + options={ + Array [ + Object { + "data-test-subj": "prometheusOption", + "label": "Prometheus", + }, + Object { + "data-test-subj": "openTelemetryOption", + "label": "OpenTelemetry", + }, + ] + } + placeholder="Select a data source" + selectedOptions={Array []} + singleSelection={ + Object { + "asPlainText": true, + } + } + sortMatchesBy="none" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + className="euiComboBox" + data-test-subj="metricsDataSourcePicker" + onKeyDown={[Function]} + role="combobox" + > + <EuiComboBoxInput + autoSizeInputRef={[Function]} + compressed={false} + fullWidth={false} + hasSelectedOptions={false} + inputRef={[Function]} + isListOpen={false} + noIcon={false} + onChange={[Function]} + onClear={[Function]} + onClick={[Function]} + onCloseListClick={[Function]} + onFocus={[Function]} + onOpenListClick={[Function]} + onRemoveOption={[Function]} + placeholder="Select a data source" + rootId={[Function]} + searchValue="" + selectedOptions={Array []} + singleSelection={ + Object { + "asPlainText": true, + } + } + toggleButtonRef={[Function]} + updatePosition={[Function]} + value="" + > + <EuiFormControlLayout + compressed={false} + fullWidth={false} + icon={ + Object { + "aria-label": "Open list of options", + "data-test-subj": "comboBoxToggleListButton", + "disabled": undefined, + "onClick": [Function], + "ref": [Function], + "side": "right", + "type": "arrowDown", + } + } + > + <div + className="euiFormControlLayout" + > + <div + className="euiFormControlLayout__childrenWrapper" + > + <div + className="euiComboBox__inputWrap euiComboBox__inputWrap--noWrap euiComboBox__inputWrap-isClearable" + data-test-subj="comboBoxInput" + onClick={[Function]} + tabIndex={-1} + > + <p + className="euiComboBoxPlaceholder" + > + Select a data source + </p> + <AutosizeInput + aria-controls="" + className="euiComboBox__input" + data-test-subj="comboBoxSearchInput" + injectStyles={true} + inputRef={[Function]} + minWidth={1} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + role="textbox" + style={ + Object { + "fontSize": 14, + } + } + value="" + > + <div + className="euiComboBox__input" + style={ + Object { + "display": "inline-block", + "fontSize": 14, + } + } + > + <input + aria-controls="" + data-test-subj="comboBoxSearchInput" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + role="textbox" + style={ + Object { + "boxSizing": "content-box", + "width": "2px", + } + } + value="" + /> + <div + style={ + Object { + "height": 0, + "left": 0, + "overflow": "scroll", + "position": "absolute", + "top": 0, + "visibility": "hidden", + "whiteSpace": "pre", + } + } + /> + </div> + </AutosizeInput> + </div> + <EuiFormControlLayoutIcons + compressed={false} + icon={ + Object { + "aria-label": "Open list of options", + "data-test-subj": "comboBoxToggleListButton", + "disabled": undefined, + "onClick": [Function], + "ref": [Function], + "side": "right", + "type": "arrowDown", + } + } + > + <div + className="euiFormControlLayoutIcons euiFormControlLayoutIcons--right" + > + <EuiFormControlLayoutCustomIcon + aria-label="Open list of options" + data-test-subj="comboBoxToggleListButton" + iconRef={[Function]} + onClick={[Function]} + size="m" + type="arrowDown" + > + <button + aria-label="Open list of options" + className="euiFormControlLayoutCustomIcon euiFormControlLayoutCustomIcon--clickable" + data-test-subj="comboBoxToggleListButton" + onClick={[Function]} + type="button" + > + <EuiIcon + aria-hidden="true" + className="euiFormControlLayoutCustomIcon__icon" + size="m" + type="arrowDown" + > + <EuiIconEmpty + aria-hidden={true} + className="euiIcon euiIcon--medium euiIcon-isLoading euiFormControlLayoutCustomIcon__icon" + focusable="false" + role="img" + style={null} + > + <svg + aria-hidden={true} + className="euiIcon euiIcon--medium euiIcon-isLoading euiFormControlLayoutCustomIcon__icon" + focusable="false" + height={16} + role="img" + style={null} + viewBox="0 0 16 16" + width={16} + xmlns="http://www.w3.org/2000/svg" + /> + </EuiIconEmpty> + </EuiIcon> + </button> + </EuiFormControlLayoutCustomIcon> + </div> + </EuiFormControlLayoutIcons> + </div> + </div> + </EuiFormControlLayout> + </EuiComboBoxInput> + </div> + </EuiComboBox> + </div> + </DataSourcePicker> + <EuiSpacer + size="s" + > + <div + className="euiSpacer euiSpacer--s" + /> + </EuiSpacer> + <EuiSpacer + size="s" + > + <div + className="euiSpacer euiSpacer--s" + /> + </EuiSpacer> <SearchBar> <div className="metrics-search-bar-input" diff --git a/public/components/metrics/sidebar/__tests__/sidebar.test.tsx b/public/components/metrics/sidebar/__tests__/sidebar.test.tsx index 79dab9ad89..2a088b4827 100644 --- a/public/components/metrics/sidebar/__tests__/sidebar.test.tsx +++ b/public/components/metrics/sidebar/__tests__/sidebar.test.tsx @@ -19,9 +19,22 @@ import { sampleSavedMetric } from '../../../../../test/metrics_constants'; jest.mock('../../../../services/requests/ppl'); +// Mocked http object +const mockHttpObject = { + get: jest.fn().mockResolvedValue({}), +}; + +// Mocked coreRefs object with the mocked http +const mockCoreRefs = { + http: mockHttpObject, + pplService: new PPLService(mockHttpObject), +}; + describe('Side Bar Component', () => { configure({ adapter: new Adapter() }); const store = createStore(rootReducer, applyMiddleware(thunk)); + const setSelectedDataSource = jest.fn(); + const setSelectedOTIndex = jest.fn(); beforeAll(() => { PPLService.mockImplementation(() => { @@ -50,7 +63,12 @@ describe('Side Bar Component', () => { const wrapper = mount( <Provider store={store}> - <Sidebar /> + <Sidebar + selectedDataSource={''} + setSelectedDataSource={setSelectedDataSource} + selectedOTIndex={''} + setSelectedOTIndex={setSelectedOTIndex} + /> </Provider> ); diff --git a/public/components/metrics/sidebar/data_source_picker.tsx b/public/components/metrics/sidebar/data_source_picker.tsx new file mode 100644 index 0000000000..e5cf7758c8 --- /dev/null +++ b/public/components/metrics/sidebar/data_source_picker.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBox, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { DATASOURCE_OPTIONS } from '../../../../common/constants/metrics'; +import { OptionType } from '../../../../common/types/metrics'; + +interface DataSourcePickerMenuProps { + selectedDataSource: OptionType[]; + setSelectedDataSource: (sources: OptionType[]) => void; +} + +export const DataSourcePicker = ({ + selectedDataSource, + setSelectedDataSource, +}: DataSourcePickerMenuProps) => { + const onChange = (selectedDataSource) => { + setSelectedDataSource(selectedDataSource); + }; + + return ( + <div className="metrics-data-source-picker"> + <EuiTitle size="xxxs"> + <h5>Data source</h5> + </EuiTitle> + <EuiComboBox + placeholder="Select a data source" + singleSelection={{ asPlainText: true }} + options={DATASOURCE_OPTIONS} + selectedOptions={selectedDataSource || []} + onChange={onChange} + data-test-subj="metricsDataSourcePicker" + /> + </div> + ); +}; diff --git a/public/components/metrics/sidebar/index_picker.tsx b/public/components/metrics/sidebar/index_picker.tsx new file mode 100644 index 0000000000..86d4c1afeb --- /dev/null +++ b/public/components/metrics/sidebar/index_picker.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBox, EuiTitle } from '@elastic/eui'; +import React, { useState } from 'react'; + +export const IndexPicker = (props: { otelIndices: unknown; setSelectedOTIndex: unknown }) => { + const { otelIndices, setSelectedOTIndex } = props; + const otelIndex = otelIndices.map((item: any) => { + return { label: item.index }; + }); + const [selectedIndex, setSelectedIndex] = useState([]); + + const onChange = (selectedIndex) => { + setSelectedIndex(selectedIndex); + setSelectedOTIndex(selectedIndex); + }; + + return ( + <div className="metrics-index-picker"> + <EuiTitle size="xxxs"> + <h5>Otel Index</h5> + </EuiTitle> + <EuiComboBox + placeholder="Select an index" + singleSelection={{ asPlainText: true }} + options={otelIndex} + selectedOptions={selectedIndex} + onChange={onChange} + data-test-subj="metricsIndexPicker" + /> + </div> + ); +}; diff --git a/public/components/metrics/sidebar/metric_name.tsx b/public/components/metrics/sidebar/metric_name.tsx index ce25c8f405..5c8bc65261 100644 --- a/public/components/metrics/sidebar/metric_name.tsx +++ b/public/components/metrics/sidebar/metric_name.tsx @@ -4,17 +4,14 @@ */ import React from 'react'; -import { EuiAvatar, EuiFacetButton, EuiIcon } from '@elastic/eui'; -import { useSelector } from 'react-redux'; -import { metricIconsSelector } from '../redux/slices/metrics_slice'; -import { OBSERVABILITY_CUSTOM_METRIC } from '../../../../common/constants/metrics'; +import { EuiFacetButton, EuiIcon } from '@elastic/eui'; +import { OBSERVABILITY_CUSTOM_METRIC, OPEN_TELEMETRY } from '../../../../common/constants/metrics'; const MetricIcon = ({ metric }) => { - const metricIcons = useSelector(metricIconsSelector); - const iconMeta = metricIcons[metric.catalog]; - if (metric.catalog === OBSERVABILITY_CUSTOM_METRIC) + const metricCatalog = metric?.catalog; + if ([OBSERVABILITY_CUSTOM_METRIC, OPEN_TELEMETRY].includes(metricCatalog)) { return <EuiIcon title="OpenSearch" type="logoOpenSearch" size="l" />; - else return <EuiAvatar name={metric.catalog} size="s" type="space" {...iconMeta} />; + } else return <EuiIcon title="OpenSearch" type="logoOpenSearch" size="l" />; }; interface IMetricNameProps { @@ -25,15 +22,19 @@ interface IMetricNameProps { export const MetricName = (props: IMetricNameProps) => { const { metric, handleClick } = props; - const name = () => { - if (metric.catalog === 'CUSTOM_METRICS') return metric.name; - else return metric.name.split('.')[1].replace(/^prometheus_/, 'p.._'); + const name = (metricDetails: any) => { + if ( + metricDetails?.catalog === OBSERVABILITY_CUSTOM_METRIC || + metricDetails?.catalog === OPEN_TELEMETRY + ) + return metricDetails?.name; + else return metricDetails?.name.split('.')[1].replace(/^prometheus_/, 'p.._'); }; return ( <EuiFacetButton className="obsMetric-Name eui-textTruncate" - title={metric.name} + title={metric?.name} onClick={() => handleClick(metric)} icon={<MetricIcon metric={metric} />} > diff --git a/public/components/metrics/sidebar/metrics_accordion.tsx b/public/components/metrics/sidebar/metrics_accordion.tsx index d3168e7102..2579d6ecaf 100644 --- a/public/components/metrics/sidebar/metrics_accordion.tsx +++ b/public/components/metrics/sidebar/metrics_accordion.tsx @@ -9,7 +9,7 @@ import { min } from 'lodash'; import { MetricName } from './metric_name'; interface IMetricNameProps { - metricsList: []; + metricsList: any; headerName: string; handleClick: (props: any) => void; dataTestSubj: string; diff --git a/public/components/metrics/sidebar/sidebar.tsx b/public/components/metrics/sidebar/sidebar.tsx index ac93037fc5..1a0adffa08 100644 --- a/public/components/metrics/sidebar/sidebar.tsx +++ b/public/components/metrics/sidebar/sidebar.tsx @@ -5,10 +5,11 @@ import './sidebar.scss'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { I18nProvider } from '@osd/i18n/react'; import { batch, useDispatch, useSelector } from 'react-redux'; +import { keyBy, sortBy } from 'lodash'; import { addSelectedMetric, availableMetricsSelector, @@ -18,28 +19,57 @@ import { selectedMetricsIdsSelector, selectedMetricsSelector, selectMetricByIdSelector, + otelIndexSelector, + setDataSourceIcons, + coloredIconsFrom, + loadOTIndices, + fetchOpenTelemetryDocumentNames, + mergeMetrics, + setSortedIds, } from '../redux/slices/metrics_slice'; import { MetricsAccordion } from './metrics_accordion'; import { SearchBar } from './search_bar'; +import { DataSourcePicker } from './data_source_picker'; +import { IndexPicker } from './index_picker'; +import { OptionType } from '../../../../common/types/metrics'; +import { OTEL_METRIC_SUBTYPE, PPL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +interface SideBarMenuProps { + selectedDataSource: OptionType[]; + setSelectedDataSource: (sources: OptionType[]) => void; + selectedOTIndex: React.SetStateAction<Array<{}>>; + setSelectedOTIndex: React.Dispatch<React.SetStateAction<unknown>>; + additionalSelectedMetricId?: string; +} export const Sidebar = ({ + selectedDataSource, + setSelectedDataSource, + selectedOTIndex, + setSelectedOTIndex, additionalSelectedMetricId, -}: { - additionalSelectedMetricId?: string; -}) => { +}: SideBarMenuProps) => { const dispatch = useDispatch(); - - const availableMetrics = useSelector(availableMetricsSelector); + const [availableOTDocuments, setAvailableOTDocuments] = useState([]); + const availableOTDocumentsRef = useRef(); + availableOTDocumentsRef.current = availableOTDocuments; + const promethuesMetrics = useSelector(availableMetricsSelector); const selectedMetrics = useSelector(selectedMetricsSelector); const selectedMetricsIds = useSelector(selectedMetricsIdsSelector); const additionalMetric = useSelector(selectMetricByIdSelector(additionalSelectedMetricId)); + const otelIndices = useSelector(otelIndexSelector); useEffect(() => { batch(() => { dispatch(loadMetrics()); }); - }, [dispatch]); + }, [dispatch, selectedDataSource]); + + useEffect(() => { + batch(() => { + dispatch(loadOTIndices()); + }); + }, [dispatch, selectedDataSource]); useEffect(() => { if (additionalMetric) { @@ -54,7 +84,57 @@ export const Sidebar = ({ return selectedMetricsIds.map((id) => selectedMetrics[id]).filter((m) => m); // filter away null entries }, [selectedMetrics, selectedMetricsIds]); - const handleAddMetric = (metric: any) => dispatch(addSelectedMetric(metric)); + useEffect(() => { + if (selectedOTIndex.length > 0 && selectedDataSource[0]?.label === 'OpenTelemetry') { + const fetchOtelDocuments = async () => { + try { + const documentNames = await fetchOpenTelemetryDocumentNames(selectedOTIndex[0]?.label)(); + const availableOtelDocuments = documentNames?.aggregations?.distinct_names?.buckets.map( + (item: any) => { + return { + id: item.key, + name: item.key, + catalog: 'OpenTelemetry', + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, + type: 'Histogram', + index: selectedOTIndex[0]?.label, + }; + } + ); + setAvailableOTDocuments(availableOtelDocuments); + const metricsMapById = keyBy(availableOtelDocuments, 'id'); + + dispatch(mergeMetrics(metricsMapById)); + + const sortedIds = sortBy(availableOtelDocuments, 'catalog', 'id').map((m) => m.id); + dispatch(setSortedIds(sortedIds)); + dispatch(setDataSourceIcons(coloredIconsFrom(['OpenTelemetry']))); + } catch (error) { + console.error('Error fetching OpenTelemetry documents:', error); + } + }; + fetchOtelDocuments(); + } + }, [dispatch, selectedDataSource, selectedOTIndex]); + + const indexPicker = useMemo(() => { + const isOpenTelemetry = selectedDataSource[0]?.label === 'OpenTelemetry' ? true : false; + if (isOpenTelemetry) { + return <IndexPicker otelIndices={otelIndices} setSelectedOTIndex={setSelectedOTIndex} />; + } + }, [selectedDataSource]); + + const availableMetrics = useMemo(() => { + if (selectedDataSource[0]?.label === 'OpenTelemetry' && selectedOTIndex.length > 0) + return promethuesMetrics; + else if (selectedDataSource[0]?.label === 'Prometheus') return promethuesMetrics; + else return []; + }, [promethuesMetrics, selectedDataSource, availableOTDocuments, selectedOTIndex]); + + const handleAddMetric = (metric: any) => { + dispatch(addSelectedMetric(metric)); + }; const handleRemoveMetric = (metric: any) => { dispatch(removeSelectedMetric(metric)); @@ -63,9 +143,15 @@ export const Sidebar = ({ return ( <I18nProvider> <div id="sidebar"> + <DataSourcePicker + selectedDataSource={selectedDataSource} + setSelectedDataSource={setSelectedDataSource} + /> + <EuiSpacer size="s" /> + {indexPicker} + <EuiSpacer size="s" /> <SearchBar /> <EuiSpacer size="s" /> - <section className="sidebar"> <MetricsAccordion metricsList={selectedMetricsList} diff --git a/public/components/metrics/top_menu/metrics_export.tsx b/public/components/metrics/top_menu/metrics_export.tsx index f23f0543dd..dee03fc1a2 100644 --- a/public/components/metrics/top_menu/metrics_export.tsx +++ b/public/components/metrics/top_menu/metrics_export.tsx @@ -21,7 +21,11 @@ import { I18nProvider } from '@osd/i18n/react'; import { MetricsExportPanel } from './metrics_export_panel'; import { OSDSavedVisualizationClient } from '../../../services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization'; import { getSavedObjectsClient } from '../../../services/saved_objects/saved_object_client/client_factory'; -import { addMultipleVizToPanels, isUuid } from '../../custom_panels/redux/panel_slice'; +import { + addMultipleVizToPanels, + isUuid, + selectPanelList, +} from '../../custom_panels/redux/panel_slice'; import { MetricType } from '../../../../common/types/metrics'; import { dateSpanFilterSelector, @@ -29,11 +33,14 @@ import { selectedMetricsSelector, } from '../redux/slices/metrics_slice'; import { coreRefs } from '../../../framework/core_refs'; -import { selectPanelList } from '../../../../public/components/custom_panels/redux/panel_slice'; import { SavedVisualization } from '../../../../common/types/explorer'; -import { visualizationFromMetric } from '../helpers/utils'; +import { visualizationFromPrometheusMetric, visualizationFromOtelMetric } from '../helpers/utils'; import { updateCatalogVisualizationQuery } from '../../common/query_utils'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +import { + OTEL_METRIC_SUBTYPE, + PROMQL_METRIC_SUBTYPE, + PPL_METRIC_SUBTYPE, +} from '../../../../common/constants/shared'; import { SavedObjectLoader } from '../../../../../../src/plugins/saved_objects/public'; import { MountPoint } from '../../../../../../src/core/public'; @@ -140,30 +147,43 @@ const MetricsExportPopOver = () => { ]); const createSavedVisualization = async (metric): Promise<any> => { - const [ds, index] = metric.index.split('.'); - const queryMetaData = { - catalogSourceName: ds, - catalogTableName: index, - aggregation: metric.aggregation, - attributesGroupBy: metric.attributesGroupBy, - }; - const visMetaData = visualizationFromMetric( - { + let visMetaData; + if (metric.metricType === OTEL_METRIC_SUBTYPE) { + visMetaData = visualizationFromOtelMetric({ ...metric, - dataSources: datasourceMetaFrom(metric.catalog), - query: updateCatalogVisualizationQuery({ - ...queryMetaData, - ...dateSpanFilter, - }), - queryMetaData, - subType: PROMQL_METRIC_SUBTYPE, + query: '', + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, dateRange: ['now-1d', 'now'], - fields: ['@value'], timestamp: '@timestamp', - }, - dateSpanFilter.span, - dateSpanFilter.reoslution - ); + }); + } else { + const [ds, index] = metric.index.split('.'); + const queryMetaData = { + catalogSourceName: ds, + catalogTableName: index, + aggregation: metric.aggregation, + attributesGroupBy: metric.attributesGroupBy, + }; + visMetaData = visualizationFromPrometheusMetric( + { + ...metric, + dataSources: datasourceMetaFrom(metric.catalog), + query: updateCatalogVisualizationQuery({ + ...queryMetaData, + ...dateSpanFilter, + }), + queryMetaData, + subType: PPL_METRIC_SUBTYPE, + metricType: PROMQL_METRIC_SUBTYPE, + dateRange: ['now-1d', 'now'], + fields: ['@value'], + timestamp: '@timestamp', + }, + dateSpanFilter.span, + dateSpanFilter.reoslution + ); + } const savedObject = await OSDSavedVisualizationClient.getInstance().create(visMetaData); return savedObject; @@ -225,7 +245,7 @@ const MetricsExportPopOver = () => { const client = dashboardsLoader.savedObjectsClient; Promise.all( - osdCoreSelectedDashboards.map(async ({ panel: dashboard }, index) => { + osdCoreSelectedDashboards.map(async ({ panel: dashboard }) => { const referenceCount = dashboard.references.length; const maxPanelY = dashboard.panelConfig.reduce(panelXYorGreaterThanValue, 0); const maxPanelVersion = dashboard.panelConfig.reduce( @@ -247,6 +267,7 @@ const MetricsExportPopOver = () => { const panelsJSON = JSON.stringify(dashboard.panelConfig); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const updateRes = await client.update( dashboard.type, dashboard.id, @@ -301,7 +322,7 @@ const MetricsExportPopOver = () => { try { savedMetrics = await Promise.all( - metricsToExport.map(async (metric, index) => { + metricsToExport.map(async (metric) => { if (metric.savedVisualizationId === undefined) { return createSavedVisualization(metric); } else { diff --git a/public/components/metrics/view/metrics_grid.tsx b/public/components/metrics/view/metrics_grid.tsx index 161431918d..7daa0908b7 100644 --- a/public/components/metrics/view/metrics_grid.tsx +++ b/public/components/metrics/view/metrics_grid.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useMemo } from 'react'; -import { EuiContextMenuItem, EuiDragDropContext, EuiDraggable, EuiDroppable } from '@elastic/eui'; +import { EuiDragDropContext, EuiDraggable, EuiDroppable } from '@elastic/eui'; import { useObservable } from 'react-use'; import { connect } from 'react-redux'; import { CoreStart } from '../../../../../../src/core/public'; @@ -21,7 +21,11 @@ import { import './metrics_grid.scss'; import { coreRefs } from '../../../framework/core_refs'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +import { + PROMQL_METRIC_SUBTYPE, + OTEL_METRIC_SUBTYPE, + observabilityLogsID, +} from '../../../../common/constants/shared'; import { MetricsEditInline } from '../sidebar/metrics_edit_inline'; import { EmptyMetricsView } from './empty_view'; @@ -32,9 +36,10 @@ interface MetricsGridProps { moveToEvents: (savedVisualizationId: string) => any; } -const visualizationFromMetric = (metric, dateSpanFilter): SavedVisualizationType => ({ +const visualizationFromPromethesMetric = (metric, dateSpanFilter): SavedVisualizationType => ({ ...metric, query: updateCatalogVisualizationQuery({ ...metric, ...dateSpanFilter }), + metricType: PROMQL_METRIC_SUBTYPE, queryMetaData: { catalogSourceName: metric.catalogSourceName, catalogTableName: metric.catalogTableName, @@ -53,18 +58,30 @@ const visualizationFromMetric = (metric, dateSpanFilter): SavedVisualizationType }, }); -const promQLActionMenu = [ - <EuiContextMenuItem - data-test-subj="showCatalogPPLQuery" - key="view_query" - onClick={() => { - closeActionsMenu(); - showModal('catalogModal'); - }} - > - View query - </EuiContextMenuItem>, -]; +const visualizationFromOtelMetric = (metric, dateSpanFilter): SavedVisualizationType => ({ + ...metric, + name: metric.name, + description: '', + query: '', + type: 'bar', + metricType: OTEL_METRIC_SUBTYPE, + selected_date_range: { + start: dateSpanFilter.start, + end: dateSpanFilter.end, + text: '', + }, + userConfigs: { + dataConfig: { + type: 'bar', + }, + }, +}); + +const visualizationFromMetric = (metric: any, dateSpanFilter: any) => { + if (metric.metricType === OTEL_METRIC_SUBTYPE) + return visualizationFromOtelMetric(metric, dateSpanFilter); + return visualizationFromPromethesMetric(metric, dateSpanFilter); +}; const navigateToEventExplorerVisualization = (savedVisualizationId: string) => { window.location.assign(`${observabilityLogsID}#/explorer/${savedVisualizationId}`); @@ -72,7 +89,6 @@ const navigateToEventExplorerVisualization = (savedVisualizationId: string) => { export const InnerGridVisualization = ({ id, idx, dateSpanFilter, metric, refresh }) => { if (!metric) return <></>; - return ( <EuiDraggable key={id} index={idx} draggableId={id}> <VisualizationContainer @@ -91,9 +107,13 @@ export const InnerGridVisualization = ({ id, idx, dateSpanFilter, metric, refres resolution={dateSpanFilter.resolution} contextMenuId="metrics" inlineEditor={ - metric.subType === PROMQL_METRIC_SUBTYPE && <MetricsEditInline visualization={metric} /> + metric.metricType === PROMQL_METRIC_SUBTYPE && ( + <MetricsEditInline visualization={metric} /> + ) } actionMenuType="metricsGrid" + metricType={metric.subType} + panelVisualization={metric} /> </EuiDraggable> ); diff --git a/public/components/visualizations/charts/__tests__/__snapshots__/histogram.test.tsx.snap b/public/components/visualizations/charts/__tests__/__snapshots__/histogram.test.tsx.snap index 944dcc5f6b..0b52d72224 100644 --- a/public/components/visualizations/charts/__tests__/__snapshots__/histogram.test.tsx.snap +++ b/public/components/visualizations/charts/__tests__/__snapshots__/histogram.test.tsx.snap @@ -694,7 +694,7 @@ exports[`Histogram component Renders histogram component 1`] = ` }, }, "name": "count()", - "type": undefined, + "type": "histogram", "x": Array [ 154, 1753, @@ -747,7 +747,7 @@ exports[`Histogram component Renders histogram component 1`] = ` }, }, "name": "count()", - "type": undefined, + "type": "histogram", "x": Array [ 154, 1753, diff --git a/public/components/visualizations/charts/lines/line.tsx b/public/components/visualizations/charts/lines/line.tsx index 539240eb83..d474f98d1a 100644 --- a/public/components/visualizations/charts/lines/line.tsx +++ b/public/components/visualizations/charts/lines/line.tsx @@ -4,7 +4,7 @@ */ import { isEmpty, last } from 'lodash'; -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import _ from 'lodash'; import { AGGREGATIONS, GROUPBY } from '../../../../../common/constants/explorer'; import { @@ -57,7 +57,7 @@ export const Line = ({ visualizations, layout, config }: any) => { availabilityConfig = {}, } = {}, }, - vis: { icontype, name }, + vis: { name }, }: IVisualizationContainerProps = visualizations; const tooltipMode = @@ -160,7 +160,7 @@ export const Line = ({ visualizations, layout, config }: any) => { visConfig = { ...visConfig, - ...processMetricsData(schema, visConfig), + ...processMetricsData(schema), }; const traceStyles = { diff --git a/public/components/visualizations/saved_object_visualization.tsx b/public/components/visualizations/saved_object_visualization.tsx index 8fbad59118..5eeeb6010c 100644 --- a/public/components/visualizations/saved_object_visualization.tsx +++ b/public/components/visualizations/saved_object_visualization.tsx @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-disable @typescript-eslint/no-unused-vars */ import _ from 'lodash'; import React, { useEffect, useState } from 'react'; @@ -13,9 +14,11 @@ import { getPPLService, preprocessQuery, removeBacktick } from '../../../common/ import { getDefaultVisConfig } from '../event_analytics/utils'; import { getVizContainerProps } from './charts/helpers'; import { Visualization } from './visualization'; -import { PROMQL_METRIC_SUBTYPE } from '../../../common/constants/shared'; -import { getMetricVisConfig } from '../event_analytics/utils/utils'; +import { OTEL_METRIC_SUBTYPE, PROMQL_METRIC_SUBTYPE } from '../../../common/constants/shared'; +import { fetchOtelMetric, getMetricVisConfig } from '../event_analytics/utils/utils'; import { preprocessMetricQuery } from '../common/query_utils'; +import { constructOtelMetricsMetaData } from '../custom_panels/helpers/utils'; +import { useToast } from '../common/toast'; interface SavedObjectVisualizationProps { savedVisualization: SavedVisualization; @@ -30,20 +33,24 @@ interface SavedObjectVisualizationProps { */ export const SavedObjectVisualization: React.FC<SavedObjectVisualizationProps> = (props) => { const [visContainerProps, setVisContainerProps] = useState<IVisualizationContainerProps>(); + const [isError, setIsError] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { setToast } = useToast(); useEffect(() => { const pplService = getPPLService(); - const isMetric = props.savedVisualization?.subType === PROMQL_METRIC_SUBTYPE; + const isPromqlMetric = props.savedVisualization?.metricType === PROMQL_METRIC_SUBTYPE; + const isOtelMetric = props.savedVisualization?.metricType === OTEL_METRIC_SUBTYPE; const metaData = { ...props.savedVisualization, query: props.savedVisualization.query, queryMetaData: props.savedVisualization.queryMetaData, - isMetric, + isPromqlMetric, }; const userConfigs = getUserConfigFrom(metaData); const dataConfig = { ...(userConfigs.dataConfig || {}) }; const hasBreakdowns = !_.isEmpty(dataConfig.breakdowns); - const realTimeParsedStats = isMetric + const realTimeParsedStats = isPromqlMetric ? getMetricVisConfig(metaData) : { ...getDefaultVisConfig(new QueryManager().queryParser().parse(metaData.query).getStats()), @@ -59,13 +66,17 @@ export const SavedObjectVisualization: React.FC<SavedObjectVisualizationProps> = ); } - const finalDataConfig = { + let finalDataConfig = { ...dataConfig, ...realTimeParsedStats, dimensions: finalDimensions, breakdowns, }; + if (isOtelMetric) { + finalDataConfig = { ...finalDataConfig, ...constructOtelMetricsMetaData() }; + } + const mixedUserConfigs = { availabilityConfig: { ...(userConfigs.availabilityConfig || {}), @@ -81,12 +92,14 @@ export const SavedObjectVisualization: React.FC<SavedObjectVisualizationProps> = let query = metaData.query; if (props.timeRange) { - if (isMetric) { + if (isPromqlMetric) { query = preprocessMetricQuery({ metaData, startTime: props.timeRange.from, endTime: props.timeRange.to, }); + } else if (isOtelMetric) { + query = ''; } else { query = preprocessQuery({ rawQuery: metaData.query, @@ -99,22 +112,50 @@ export const SavedObjectVisualization: React.FC<SavedObjectVisualizationProps> = } } - pplService - .fetch({ query, format: 'jdbc' }) - .then((data) => { - const container = getVizContainerProps({ - vizId: props.savedVisualization.type, - rawVizData: data, - query: { rawQuery: metaData.query }, - indexFields: {}, - userConfigs: mixedUserConfigs, - explorer: { explorerData: data, explorerFields: data.schema }, - }); - setVisContainerProps(container); + if (isOtelMetric) { + const visualizationName = props.savedVisualization?.name; + const startTime = props.timeRange?.from; + const endTime = props.timeRange?.to; + const data = fetchOtelMetric({ + visualizationName, + startTime, + endTime, + setIsError, + setIsLoading, + setToast, }) - .catch((error: Error) => { - console.error(error); - }); + .then((jsonData) => { + const container = getVizContainerProps({ + vizId: props.savedVisualization.type, + rawVizData: data, + query: { rawQuery: metaData.query }, + indexFields: {}, + userConfigs: mixedUserConfigs, + explorer: { explorerData: jsonData }, + }); + setVisContainerProps(container); + }) + .catch((error: Error) => { + console.error(error); + }); + } else { + pplService + .fetch({ query, format: 'jdbc' }) + .then((data) => { + const container = getVizContainerProps({ + vizId: props.savedVisualization.type, + rawVizData: data, + query: { rawQuery: metaData.query }, + indexFields: {}, + userConfigs: mixedUserConfigs, + explorer: { explorerData: data, explorerFields: data.schema }, + }); + setVisContainerProps(container); + }) + .catch((error: Error) => { + console.error(error); + }); + } }, [props]); return visContainerProps ? <Visualization visualizations={visContainerProps} /> : null; diff --git a/public/components/visualizations/visualization.tsx b/public/components/visualizations/visualization.tsx index 25e7bbf363..14c25e8ea5 100644 --- a/public/components/visualizations/visualization.tsx +++ b/public/components/visualizations/visualization.tsx @@ -29,14 +29,14 @@ export const Visualization = ({ [GROUPBY]: dimensions = [], [AGGREGATIONS]: series = [], queryMetaData = {}, - subType = '', + metricType = '', } = {}, } = {}, }, vis = {}, }: IVisualizationContainerProps = vs; - if (subType === PROMQL_METRIC_SUBTYPE) { + if (metricType === PROMQL_METRIC_SUBTYPE) { if (isEmpty(queryMetaData)) return [false, VISUALIZATION_ERROR.NO_METRIC]; // Metric checks OK. @@ -55,7 +55,6 @@ export const Visualization = ({ }; const [isValid, erroInfo] = isVisDataValid(visualizations); - return ( <> {isValid ? ( diff --git a/public/components/visualizations/visualization_chart.tsx b/public/components/visualizations/visualization_chart.tsx index 7f84619785..3700f758db 100644 --- a/public/components/visualizations/visualization_chart.tsx +++ b/public/components/visualizations/visualization_chart.tsx @@ -9,7 +9,6 @@ export const VisualizationChart = ({ visualizations }) => { const { vis } = visualizations; const { layout = {}, config = {} } = visualizations?.data?.userConfigs; const Visualization = visualizations?.vis?.component; - const finalFigureConfig = useMemo(() => { return { ...vis.visconfig?.config, diff --git a/public/embeddable/observability_embeddable.tsx b/public/embeddable/observability_embeddable.tsx index 3cf75f404d..f21299fab7 100644 --- a/public/embeddable/observability_embeddable.tsx +++ b/public/embeddable/observability_embeddable.tsx @@ -18,7 +18,11 @@ import { VisualizationSavedObjectAttributes, } from '../../common/types/observability_saved_object_attributes'; import { ObservabilityEmbeddableComponent } from './observability_embeddable_component'; -import { observabilityMetricsID, PROMQL_METRIC_SUBTYPE } from '../../common/constants/shared'; +import { + observabilityMetricsID, + PROMQL_METRIC_SUBTYPE, + OTEL_METRIC_SUBTYPE, +} from '../../common/constants/shared'; // this needs to match the saved object type for the clone and replace panel actions to work export const OBSERVABILITY_EMBEDDABLE = VISUALIZATION_SAVED_OBJECT; @@ -80,8 +84,9 @@ export class ObservabilityEmbeddable extends Embeddable< public async reload() { this.attributes = await this.attributeService.unwrapAttributes(this.input); - - const isMetric = this.attributes?.savedVisualization?.subType === PROMQL_METRIC_SUBTYPE; + const isMetric = + this.attributes?.savedVisualization?.metricType === + (PROMQL_METRIC_SUBTYPE || OTEL_METRIC_SUBTYPE); if (isMetric) { const editPath = `#/${VISUALIZATION_SAVED_OBJECT}:${this.savedObjectId}`; const editUrl = `/app/${observabilityMetricsID}${editPath}`; diff --git a/public/services/saved_objects/event_analytics/saved_objects.ts b/public/services/saved_objects/event_analytics/saved_objects.ts index cb423f657f..22267611ae 100644 --- a/public/services/saved_objects/event_analytics/saved_objects.ts +++ b/public/services/saved_objects/event_analytics/saved_objects.ts @@ -2,9 +2,10 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-disable no-unused-vars */ import { has, isArray, isEmpty } from 'lodash'; -import { IField } from 'common/types/explorer'; +import { IField } from '../../../../common/types/explorer'; import { EVENT_ANALYTICS, OBSERVABILITY_BASE, @@ -58,6 +59,7 @@ export default class SavedObjects { applicationId = '', userConfigs = '', subType = '', + metricType = '', unitsOfMeasure = '', selectedLabels, }: any) { @@ -98,6 +100,10 @@ export default class SavedObjects { objRequest.object.subType = subType; } + if (!isEmpty(metricType)) { + objRequest.object.metricType = metricType; + } + if (!isEmpty(unitsOfMeasure)) { objRequest.object.units_of_measure = unitsOfMeasure; } @@ -161,6 +167,7 @@ export default class SavedObjects { userConfigs: params.userConfigs, description: params.description, subType: params.subType, + metricType: params.metricType, unitsOfMeasure: params.unitsOfMeasure, selectedLabels: params.selectedLabels, }); @@ -224,6 +231,7 @@ export default class SavedObjects { userConfigs: params.userConfigs, description: params.description, subType: params.subType, + metricType: params.metricType, unitsOfMeasure: params.unitsOfMeasure, selectedLabels: params.selectedLabels, }); diff --git a/public/services/saved_objects/saved_object_client/osd_saved_objects/osd_saved_object_client.ts b/public/services/saved_objects/saved_object_client/osd_saved_objects/osd_saved_object_client.ts index 323242eb07..57bf9cf183 100644 --- a/public/services/saved_objects/saved_object_client/osd_saved_objects/osd_saved_object_client.ts +++ b/public/services/saved_objects/saved_object_client/osd_saved_objects/osd_saved_object_client.ts @@ -74,6 +74,7 @@ export abstract class OSDSavedObjectClient extends SavedObjectClientBase { applicationId = '', userConfigs = '', subType = '', + metricType = '', unitsOfMeasure = '', selectedLabels, objectId = '', @@ -118,6 +119,10 @@ export abstract class OSDSavedObjectClient extends SavedObjectClientBase { objRequest.object.subType = subType; } + if (!isEmpty(metricType)) { + objRequest.object.metricType = metricType; + } + if (!isEmpty(unitsOfMeasure)) { objRequest.object.units_of_measure = unitsOfMeasure; } diff --git a/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_searches.ts b/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_searches.ts index 45b5e3de4f..be17739f45 100644 --- a/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_searches.ts +++ b/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_searches.ts @@ -32,6 +32,7 @@ interface CommonParams { userConfigs: any; description: string; subType: string; + metricType: string; unitsOfMeasure: string; selectedLabels: string; dataSources: string; // list of type SelectedDataSources that is stringified diff --git a/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization.ts b/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization.ts index 045fa3e507..c09d35ced1 100644 --- a/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization.ts +++ b/public/services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization.ts @@ -33,6 +33,7 @@ interface CommonParams { userConfigs: any; description: string; subType: string; + metricType: string; unitsOfMeasure: string; selectedLabels: string; dataSources: string; // list of type SelectedDataSources that is stringified @@ -64,6 +65,7 @@ export class OSDSavedVisualizationClient extends OSDSavedObjectClient { userConfigs: params.userConfigs, description: params.description, subType: params.subType, + metricType: params.metricType, unitsOfMeasure: params.unitsOfMeasure, selectedLabels: params.selectedLabels, dataSources: params.dataSources, @@ -104,6 +106,7 @@ export class OSDSavedVisualizationClient extends OSDSavedObjectClient { userConfigs: params.userConfigs, description: params.description, subType: params.subType, + metricType: params.metricType, unitsOfMeasure: params.unitsOfMeasure, selectedLabels: params.selectedLabels, dataSources: params.dataSources, @@ -160,6 +163,7 @@ export class OSDSavedVisualizationClient extends OSDSavedObjectClient { createdTimeMs: o.attributes.createdTimeMs, lastUpdatedTimeMs: OSDSavedObjectClient.convertToLastUpdatedMs(o.updated_at), savedVisualization: o.attributes.savedVisualization, + metricType: o.attributes.metricType, })) ); return { totalHits: observabilityObjectList.length, observabilityObjectList }; diff --git a/public/services/saved_objects/saved_object_client/ppl/ppl_client.ts b/public/services/saved_objects/saved_object_client/ppl/ppl_client.ts index e7573820d8..32aa843d4d 100644 --- a/public/services/saved_objects/saved_object_client/ppl/ppl_client.ts +++ b/public/services/saved_objects/saved_object_client/ppl/ppl_client.ts @@ -82,6 +82,7 @@ export class PPLSavedObjectClient extends SavedObjectClientBase implements ISave applicationId = '', userConfigs = '', subType = '', + metricType = '', unitsOfMeasure = '', selectedLabels, objectId = '', @@ -123,6 +124,10 @@ export class PPLSavedObjectClient extends SavedObjectClientBase implements ISave objRequest.object.subType = subType; } + if (!isEmpty(metricType)) { + objRequest.object.metricType = metricType; + } + if (!isEmpty(unitsOfMeasure)) { objRequest.object.units_of_measure = unitsOfMeasure; } diff --git a/public/services/saved_objects/saved_object_client/ppl/saved_visualization.ts b/public/services/saved_objects/saved_object_client/ppl/saved_visualization.ts index 5540412940..38116698f9 100644 --- a/public/services/saved_objects/saved_object_client/ppl/saved_visualization.ts +++ b/public/services/saved_objects/saved_object_client/ppl/saved_visualization.ts @@ -25,6 +25,7 @@ interface CommonParams { userConfigs: any; description: string; subType: string; + metricType: string; unitsOfMeasure: string; selectedLabels: string; } @@ -51,6 +52,7 @@ export class PPLSavedVisualizationClient extends PPLSavedObjectClient { userConfigs: params.userConfigs, description: params.description, subType: params.subType, + metricType: params.metricType, unitsOfMeasure: params.unitsOfMeasure, selectedLabels: params.selectedLabels, }) @@ -74,6 +76,7 @@ export class PPLSavedVisualizationClient extends PPLSavedObjectClient { userConfigs: params.userConfigs, description: params.description, subType: params.subType, + metricType: params.metricType, unitsOfMeasure: params.unitsOfMeasure, selectedLabels: params.selectedLabels, objectId: params.objectId, diff --git a/public/services/saved_objects/saved_object_client/saved_objects_actions.ts b/public/services/saved_objects/saved_object_client/saved_objects_actions.ts index 3a1fc5a664..55ae3225ec 100644 --- a/public/services/saved_objects/saved_object_client/saved_objects_actions.ts +++ b/public/services/saved_objects/saved_object_client/saved_objects_actions.ts @@ -46,16 +46,19 @@ export class SavedObjectsActions { static async getBulk<T extends ObservabilitySavedObject>( params: ISavedObjectRequestParams ): Promise<SavedObjectsGetResponse<T>> { - const objects = await PPLSavedQueryClient.getInstance().getBulk(params); + let objects = await PPLSavedQueryClient.getInstance().getBulk(params); if (params.objectType?.includes('savedVisualization')) { const osdVisualizationObjects = await OSDSavedVisualizationClient.getInstance().getBulk(); if (objects.totalHits && osdVisualizationObjects.totalHits) { objects.totalHits += osdVisualizationObjects.totalHits; } - objects.observabilityObjectList = [ - ...objects.observabilityObjectList, - ...osdVisualizationObjects.observabilityObjectList, - ]; + objects = { + ...objects, + observabilityObjectList: [ + ...objects.observabilityObjectList, + ...osdVisualizationObjects.observabilityObjectList, + ], + }; } if (params.objectType?.includes('savedQuery')) { @@ -63,10 +66,13 @@ export class SavedObjectsActions { if (objects.totalHits && osdSearchObjects.totalHits) { objects.totalHits += osdSearchObjects.totalHits; } - objects.observabilityObjectList = [ - ...objects.observabilityObjectList, - ...osdSearchObjects.observabilityObjectList, - ]; + objects = { + ...objects, + observabilityObjectList: [ + ...objects.observabilityObjectList, + ...osdSearchObjects.observabilityObjectList, + ], + }; } if (params.sortOrder === 'asc') { diff --git a/server/adaptors/custom_panels/custom_panel_adaptor.ts b/server/adaptors/custom_panels/custom_panel_adaptor.ts index d82251ab96..d7ce214625 100644 --- a/server/adaptors/custom_panels/custom_panel_adaptor.ts +++ b/server/adaptors/custom_panels/custom_panel_adaptor.ts @@ -228,6 +228,9 @@ export class CustomPanelsAdaptor { subType: visualization.savedVisualization.hasOwnProperty('subType') ? visualization.savedVisualization.subType : '', + metricType: visualization.savedVisualization.hasOwnProperty('metricType') + ? visualization.savedVisualization.metricType + : '', units_of_measure: visualization.savedVisualization.hasOwnProperty('units_of_measure') ? visualization.savedVisualization.units_of_measure : '', diff --git a/server/adaptors/metrics/metrics_analytics_adaptor.ts b/server/adaptors/metrics/metrics_analytics_adaptor.ts new file mode 100644 index 0000000000..1fde1e3956 --- /dev/null +++ b/server/adaptors/metrics/metrics_analytics_adaptor.ts @@ -0,0 +1,150 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ILegacyScopedClusterClient } from '../../../../../src/core/server'; + +export class MetricsAnalyticsAdaptor { + fetch = async function (client: ILegacyScopedClusterClient, query: any, index: string) { + try { + const response = await client.callAsCurrentUser('search', { + body: query, + index, + }); + return response; + } catch (error) { + throw new Error('Fetch Otel Metrics Error:' + error); + } + }; + + queryToFetchBinCount = async ( + client: ILegacyScopedClusterClient, + min: string, + max: string, + startTime: string, + endTime: string, + documentName: string, + index: string + ) => { + const metricsQuery = { + size: 0, + query: { + bool: { + must: [ + { + term: { + 'name.keyword': { + value: documentName, + }, + }, + }, + { + range: { + time: { + gte: startTime, + lte: endTime, + }, + }, + }, + ], + }, + }, + aggs: { + nested_buckets: { + nested: { + path: 'buckets', + }, + aggs: { + bucket_range: { + filter: { + range: { + 'buckets.max': { + gt: min, + lte: max, + }, + }, + }, + aggs: { + bucket_count: { + sum: { + field: 'buckets.count', + }, + }, + }, + }, + }, + }, + }, + }; + + try { + const response = await this.fetch(client, metricsQuery, index); + return response.aggregations; + } catch (error) { + throw new Error('Fetch Bin count Error:' + error); + } + }; + + queryToFetchSampleDocument = async ( + client: ILegacyScopedClusterClient, + documentName: string, + index: string + ) => { + const metricsQuery = { + size: 1, + query: { + bool: { + must: [ + { + term: { + 'name.keyword': { + value: documentName, + }, + }, + }, + ], + }, + }, + }; + + try { + const response = await this.fetch(client, metricsQuery, index); + return response; + } catch (error) { + throw new Error('Fetch Sample Document Error:' + error); + } + }; + + queryToFetchDocumentNames = async (client: ILegacyScopedClusterClient, index: string) => { + const metricsQuery = { + size: 0, + query: { + bool: { + filter: [ + { + term: { + kind: 'HISTOGRAM', + }, + }, + ], + }, + }, + aggs: { + distinct_names: { + terms: { + field: 'name.keyword', + size: 500, + }, + }, + }, + }; + + try { + const response = await this.fetch(client, metricsQuery, index); + return response; + } catch (error) { + throw new Error('Fetch Document Names Error:' + error); + } + }; +} diff --git a/server/routes/event_analytics/event_analytics_router.ts b/server/routes/event_analytics/event_analytics_router.ts index b23cfc181f..57ed898dd0 100644 --- a/server/routes/event_analytics/event_analytics_router.ts +++ b/server/routes/event_analytics/event_analytics_router.ts @@ -139,6 +139,7 @@ export const registerEventAnalyticsRouter = ({ application_id: schema.maybe(schema.string()), userConfigs: schema.maybe(schema.string()), subType: schema.maybe(schema.string()), + metricType: schema.maybe(schema.string()), units_of_measure: schema.maybe(schema.string()), selected_labels: schema.maybe( schema.object({ @@ -233,6 +234,7 @@ export const registerEventAnalyticsRouter = ({ application_id: schema.maybe(schema.string()), userConfigs: schema.maybe(schema.string()), subType: schema.maybe(schema.string()), + metricType: schema.maybe(schema.string()), units_of_measure: schema.maybe(schema.string()), selected_labels: schema.maybe( schema.object({ diff --git a/server/routes/metrics/metrics_rounter.ts b/server/routes/metrics/metrics_rounter.ts index e207701f07..3891d392d7 100644 --- a/server/routes/metrics/metrics_rounter.ts +++ b/server/routes/metrics/metrics_rounter.ts @@ -5,11 +5,19 @@ import { ResponseError } from '@opensearch-project/opensearch/lib/errors'; import { schema } from '@osd/config-schema'; -import { IOpenSearchDashboardsResponse, IRouter } from '../../../../../src/core/server'; +import { + ILegacyScopedClusterClient, + IOpenSearchDashboardsResponse, + IRouter, +} from '../../../../../src/core/server'; import { OBSERVABILITY_BASE } from '../../../common/constants/shared'; import { addClickToMetric, getMetrics } from '../../common/metrics/metrics_helper'; +import { MetricsAnalyticsAdaptor } from '../../adaptors/metrics/metrics_analytics_adaptor'; +import { DATA_PREPPER_INDEX_NAME } from '../../../common/constants/metrics'; export function registerMetricsRoute(router: IRouter) { + const metricsAnalyticsBackend = new MetricsAnalyticsAdaptor(); + router.get( { path: `${OBSERVABILITY_BASE}/stats`, @@ -62,4 +70,152 @@ export function registerMetricsRoute(router: IRouter) { } } ); + + router.get( + { + path: `${OBSERVABILITY_BASE}/search/indices`, + validate: {}, + }, + async (context, request, response) => { + const params = { + format: 'json', + index: DATA_PREPPER_INDEX_NAME, + }; + try { + const resp = await context.core.opensearch.legacy.client.callAsCurrentUser( + 'cat.indices', + params + ); + return response.ok({ + body: resp, + }); + } catch (error) { + if (error.statusCode !== 404) console.error(error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.get( + { + path: `${OBSERVABILITY_BASE}/metrics/otel/{index}/documentNames`, + validate: { + params: schema.object({ + index: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise<IOpenSearchDashboardsResponse<any | ResponseError>> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + + try { + const resp = await metricsAnalyticsBackend.queryToFetchDocumentNames( + opensearchNotebooksClient, + request.params.index + ); + return response.ok({ + body: resp, + }); + } catch (error) { + if (error.statusCode !== 404) console.error(error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.get( + { + path: `${OBSERVABILITY_BASE}/metrics/otel/{index}/{histogramSampleDocument}`, + validate: { + params: schema.object({ + histogramSampleDocument: schema.string(), + index: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise<IOpenSearchDashboardsResponse<any | ResponseError>> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + + try { + const resp = await metricsAnalyticsBackend.queryToFetchSampleDocument( + opensearchNotebooksClient, + request.params.histogramSampleDocument, + request.params.index + ); + return response.ok({ + body: resp.hits, + }); + } catch (error) { + if (error.statusCode !== 404) console.error(error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.post( + { + path: `${OBSERVABILITY_BASE}/metrics/otel/aggregatedBinCount`, + validate: { + body: schema.object({ + min: schema.string(), + max: schema.string(), + startTime: schema.string(), + endTime: schema.string(), + documentName: schema.string(), + index: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise<IOpenSearchDashboardsResponse<any | ResponseError>> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + + try { + const resp = await metricsAnalyticsBackend.queryToFetchBinCount( + opensearchNotebooksClient, + request.body.min, + request.body.max, + request.body.startTime, + request.body.endTime, + request.body.documentName, + request.body.index + ); + return response.ok({ + body: resp, + }); + } catch (error) { + if (error.statusCode !== 404) console.error(error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); }