diff --git a/.github/workflows/draft-release-notes-workflow.yml b/.github/workflows/draft-release-notes-workflow.yml index 6b3d89c..fcc2620 100644 --- a/.github/workflows/draft-release-notes-workflow.yml +++ b/.github/workflows/draft-release-notes-workflow.yml @@ -2,19 +2,38 @@ name: Release Drafter on: push: + # branches to consider in the event; optional, defaults to all branches: - - main + - master + # pull_request event is required only for autolabeler + pull_request: + # Only following types are handled by the action, but one can default to all as well + types: [opened, reopened, synchronize] + # pull_request_target event is required for autolabeler to support PRs from forks + # pull_request_target: + # types: [opened, reopened, synchronize] + +permissions: + contents: read jobs: update_release_draft: - name: Update draft release notes + permissions: + # write permission is required to create a github release + contents: write + # write permission is required for autolabeler + # otherwise, read permission is required at least + pull-requests: write runs-on: ubuntu-latest steps: - - name: Update draft release notes - uses: release-drafter/release-drafter@v5 + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v5 + # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml with: config-name: draft-release-notes-config.yml - name: Version (set here) - tag: (None) + disable-autolabeler: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index 211109c..7900c1f 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -5,6 +5,7 @@ on: [pull_request, push] env: PLUGIN_NAME: dashboards-search-relevance + jobs: build: strategy: diff --git a/.opensearch_dashboards-plugin-helpers.json b/.opensearch_dashboards-plugin-helpers.json new file mode 100644 index 0000000..2ae9273 --- /dev/null +++ b/.opensearch_dashboards-plugin-helpers.json @@ -0,0 +1,11 @@ +{ + "serverSourcePatterns": [ + "package.json", + "tsconfig.json", + "yarn.lock", + ".yarnrc", + "{lib,public,server,webpackShims,translations,utils,models,common}/**/*", + "!__tests__", + "config.ts" + ] + } \ No newline at end of file diff --git a/MAINTAINERS.md b/MAINTAINERS.md index d318d1b..9214747 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -8,4 +8,6 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | ------------ | ------------------------------------- | ----------- | | Mark Cohen | [macohen](https://github.com/macohen) | Amazon | | Michael Froh | [msfroh](https://github.com/msfroh) | Amazon | -| Mingshi Liu | [mingshl](https://github.com/mingshl) | Amazon | \ No newline at end of file +| Mingshi Liu | [mingshl](https://github.com/mingshl) | Amazon | +| Louis Chu | [noCharger](https://github.com/noCharger) | Amazon | +| Sean Li | [sejli](https://github.com/sejli) | Amazon | diff --git a/common/index.ts b/common/index.ts index db156dd..fcb74f2 100644 --- a/common/index.ts +++ b/common/index.ts @@ -9,4 +9,5 @@ export const PLUGIN_NAME = 'Search Relevance'; export enum ServiceEndpoints { GetIndexes = '/api/relevancy/search/indexes', GetSearchResults = '/api/relevancy/search', + GetStats = '/api/relevancy/stats', } diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..5d201bd --- /dev/null +++ b/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +import { METRIC_INTERVAL, DEFAULT_WINDOW_SIZE } from './server/metrics'; + +export const configSchema = schema.object({ + metrics: schema.object({ + metricInterval: schema.number({ defaultValue: METRIC_INTERVAL.ONE_MINUTE }), + windowSize: schema.number({ min: 2, max: 10, defaultValue: DEFAULT_WINDOW_SIZE }), + }), +}); + +export type SearchRelevancePluginConfigType = TypeOf; diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 6af452a..1d53fbb 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -1,10 +1,10 @@ { "id": "searchRelevanceDashboards", - "version": "2.5.0.0", - "opensearchDashboardsVersion": "2.5.0", + "version": "2.8.0.0", + "opensearchDashboardsVersion": "2.8.0", "server": true, "ui": true, "requiredPlugins": [ "navigation" ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index bbe3912..d949694 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "searchRelevanceDashboards", - "version": "2.5.0.0", + "version": "2.8.0.0", "main": "./public/index.ts", "license": "Apache-2.0", "scripts": { @@ -22,4 +22,4 @@ "glob-parent": "^6.0.1", "qs": "~6.5.3" } -} +} \ No newline at end of file diff --git a/public/components/query_compare/search_result/index.tsx b/public/components/query_compare/search_result/index.tsx index 82d0ecd..e9a65d3 100644 --- a/public/components/query_compare/search_result/index.tsx +++ b/public/components/query_compare/search_result/index.tsx @@ -38,85 +38,97 @@ export const SearchResult = ({ http }: SearchResultProps) => { } = useSearchRelevanceContext(); const onClickSearch = () => { - const queryError1: QueryError = { ...initialQueryErrorState }; - const queryError2: QueryError = { ...initialQueryErrorState }; + const queryErrors = [{ ...initialQueryErrorState }, { ...initialQueryErrorState }]; + const jsonQueries = [{}, {}]; + validateQuery(selectedIndex1, queryString1, queryErrors[0]); + jsonQueries[0] = rewriteQuery(searchBarValue, queryString1, queryErrors[0]); + + validateQuery(selectedIndex2, queryString2, queryErrors[1]); + jsonQueries[1] = rewriteQuery(searchBarValue, queryString2, queryErrors[1]); + + handleQuery(jsonQueries, queryErrors); + }; + + const validateQuery = (selectedIndex: string, queryString: string, queryError: QueryError) => { // Check if select an index - if (!selectedIndex1.length) { - queryError1.selectIndex = 'An index is required. Select an index.'; - } - if (!selectedIndex2.length) { - queryError2.selectIndex = 'An index is required. Select an index.'; + if (!selectedIndex.length) { + queryError.selectIndex = 'An index is required. Select an index.'; } // Check if query string is empty - if (!queryString1.length) { - queryError1.queryString = QueryStringError.empty; - } - if (!queryString2.length) { - queryError2.queryString = QueryStringError.empty; + if (!queryString.length) { + queryError.queryString = QueryStringError.empty; } + }; - // Check if query string is valid - let jsonQuery1 = {}; - let jsonQuery2 = {}; - if (queryString1.trim().length > 0) { + const rewriteQuery = (searchBarValue: string, queryString: string, queryError: QueryError) => { + if (queryString.trim().length > 0) { try { - jsonQuery1 = JSON.parse(queryString1.replace(/%SearchText%/g, searchBarValue)); + return JSON.parse(queryString.replace(/%SearchText%/g, searchBarValue)); } catch { - queryError1.queryString = QueryStringError.invalid; - } - } - if (queryString2.trim().length > 0) { - try { - jsonQuery2 = JSON.parse(queryString2.replace(/%SearchText%/g, searchBarValue)); - } catch { - queryError2.queryString = QueryStringError.invalid; + queryError.queryString = QueryStringError.invalid; } } + }; + + const handleQuery = (jsonQueries: any, queryErrors: QueryError[]) => { + let requestBody = {}; // Handle query1 - if (queryError1.queryString.length || queryError1.selectIndex.length) { - setQueryError1(queryError1); + if (queryErrors[0].queryString.length || queryErrors[0].selectIndex.length) { + setQueryError1(queryErrors[0]); setQueryResult1({} as any); updateComparedResult1({} as any); - } else if (!queryError1.queryString.length && !queryError1.selectIndex.length) { - http - .post(ServiceEndpoints.GetSearchResults, { - body: JSON.stringify({ index: selectedIndex1, ...jsonQuery1 }), - }) - .then((res) => { - setQueryResult1(res); - updateComparedResult1(res); - }) - .catch((error: Error) => { - setQueryError1({ - ...queryError1, - queryString: error.body.message, - }); - console.error(error); - }); + } else if (!queryErrors[0].queryString.length && !queryErrors[0].selectIndex.length) { + requestBody = { + query1: { index: selectedIndex1, ...jsonQueries[0] }, + }; } // Handle query2 - if (queryError2.queryString.length || queryError2.selectIndex.length) { - setQueryError2(queryError2); + if (queryErrors[1].queryString.length || queryErrors[1].selectIndex.length) { + setQueryError2(queryErrors[1]); setQueryResult2({} as any); updateComparedResult2({} as any); - } else if (!queryError2.queryString.length && !queryError2.selectIndex.length) { + } else if (!queryErrors[1].queryString.length && !queryErrors[1].selectIndex.length) { + requestBody = { + ...requestBody, + query2: { index: selectedIndex2, ...jsonQueries[1] }, + }; + } + + if (Object.keys(requestBody).length !== 0) { http .post(ServiceEndpoints.GetSearchResults, { - body: JSON.stringify({ index: selectedIndex2, ...jsonQuery2 }), + body: JSON.stringify(requestBody), }) .then((res) => { - setQueryResult2(res); - updateComparedResult2(res); + if (res.result1) { + setQueryResult1(res.result1); + updateComparedResult1(res.result1); + } + + if (res.result2) { + setQueryResult2(res.result2); + updateComparedResult2(res.result2); + } + + if (res.errorMessage1) { + setQueryError1({ + ...queryErrors[0], + queryString: res.errorMessage1, + }); + } + + if (res.errorMessage2) { + setQueryError2({ + ...queryErrors[1], + queryString: res.errorMessage2, + }); + } }) .catch((error: Error) => { - setQueryError2({ - ...queryError2, - queryString: error.body.message, - }); console.error(error); }); } diff --git a/release-notes/opensearch-dashboards-search-relevance.release-notes-2.7.0.0.md b/release-notes/opensearch-dashboards-search-relevance.release-notes-2.7.0.0.md new file mode 100644 index 0000000..fa114ab --- /dev/null +++ b/release-notes/opensearch-dashboards-search-relevance.release-notes-2.7.0.0.md @@ -0,0 +1,15 @@ +## What's Changed +### Features +* [Feature] Exposing Metrics for Search Comparison Tool by @noCharger in https://github.com/opensearch-project/dashboards-search-relevance/pull/162 + +### Bug Fixes & Maintainence +* [Bug][Build] export config file by @kavilla in https://github.com/opensearch-project/dashboards-search-relevance/pull/183 +* removing stats.yml until an alternate can be found that can publish Pā€¦ by @macohen in https://github.com/opensearch-project/dashboards-search-relevance/pull/110 + +## New Maintainers +* New Maintainers - Sean Li and Louis Chu by @macohen in https://github.com/opensearch-project/dashboards-search-relevance/pull/172 +## New Contributors +* @kavilla made their first contribution in https://github.com/opensearch-project/dashboards-search-relevance/pull/183 + +**Full Changelog**: https://github.com/opensearch-project/dashboards-search-relevance/commits/2.7.0.0 + diff --git a/server/index.ts b/server/index.ts index aae16ef..52ce5ff 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,11 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PluginInitializerContext } from '../../../src/core/server'; +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../src/core/server'; import { SearchRelevancePlugin } from './plugin'; +import { configSchema, SearchRelevancePluginConfigType } from '../config'; -// This exports static code and TypeScript types, -// as well as, OpenSearch Dashboards Platform `plugin()` initializer. +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; export function plugin(initializerContext: PluginInitializerContext) { return new SearchRelevancePlugin(initializerContext); diff --git a/server/metrics/index.ts b/server/metrics/index.ts new file mode 100644 index 0000000..1130265 --- /dev/null +++ b/server/metrics/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { MetricsServiceSetup, MetricsService } from './metrics_service'; + +export enum METRIC_INTERVAL { + ONE_SECOND = 1000, + ONE_MINUTE = 60000, +} + +export const DEFAULT_WINDOW_SIZE = 3; + +export enum METRIC_NAME { + SEARCH_RELEVANCE = 'search_relevance', +} + +export enum METRIC_ACTION { + COMPARISON_SEARCH = 'comparison_search', + SINGLE_SEARCH = 'single_search', + FETCH_INDEX = 'fetch_index', +} diff --git a/server/metrics/metrics_service.test.ts b/server/metrics/metrics_service.test.ts new file mode 100644 index 0000000..5095e34 --- /dev/null +++ b/server/metrics/metrics_service.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricsService, MetricsServiceSetup } from './'; + +describe('MetricsService', () => { + let metricsService: MetricsService; + let setup: MetricsServiceSetup; + + beforeEach(() => { + metricsService = new MetricsService(); + setup = metricsService.setup(); + }); + + afterEach(() => { + metricsService.stop(); + }); + + describe('test addMetric and getStats', () => { + it('should add metrics to the correct interval and ignore metrics in the future', () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(0); + setup.addMetric('component1', 'action1', 200, 100); + jest.advanceTimersByTime(30000); + setup.addMetric('component1', 'action1', 200, 200); + setup.addMetric('component1', 'action1', 200, 300); + jest.advanceTimersByTime(30000); + setup.addMetric('component1', 'action1', 200, 400); + + const stats = setup.getStats(); + expect(stats.data['component1']['action1'][200]).toEqual({ + response_time_total: 600, + count: 3, + }); + expect(stats.overall.response_time_avg).toEqual(200); + expect(stats.overall.requests_per_second).toEqual(0.05); + expect(stats.counts_by_component['component1']).toEqual(3); + expect(stats.counts_by_status_code[200]).toEqual(3); + }); + + it('should add metrics to the correct component', () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(0); + setup.addMetric('component1', 'action1', 200, 100); + setup.addMetric('component2', 'action1', 200, 200); + setup.addMetric('component1', 'action2', 400, 300); + + jest.advanceTimersByTime(60000); + const stats = setup.getStats(); + expect(stats.counts_by_component['component1']).toEqual(2); + expect(stats.counts_by_component['component2']).toEqual(1); + expect(stats.counts_by_status_code[200]).toEqual(2); + }); + + it('should add metrics to the correct action', () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(0); + setup.addMetric('component1', 'action1', 200, 100); + setup.addMetric('component1', 'action2', 300, 200); + setup.addMetric('component2', 'action1', 400, 300); + + jest.advanceTimersByTime(60000); + const stats = setup.getStats(); + expect(stats.data['component1']['action1'][200].count).toEqual(1); + expect(stats.data['component1']['action2'][300].count).toEqual(1); + expect(stats.data['component2']['action1'][400].count).toEqual(1); + }); + + it('should add metrics to the correct status code', () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(0); + setup.addMetric('component1', 'action1', 200, 100); + setup.addMetric('component1', 'action1', 400, 200); + setup.addMetric('component1', 'action1', 500, 300); + + jest.advanceTimersByTime(60000); + const stats = setup.getStats(); + expect(stats.counts_by_status_code[200]).toEqual(1); + expect(stats.counts_by_status_code[400]).toEqual(1); + expect(stats.counts_by_status_code[500]).toEqual(1); + }); + }); + + describe('resetMetrics', () => { + it('should clear all metrics data', () => { + setup.addMetric('component1', 'action1', 200, 100); + metricsService.resetMetrics(); + const stats = setup.getStats(); + expect(stats.data).toEqual({}); + expect(stats.overall).toEqual({ response_time_avg: 0, requests_per_second: 0 }); + expect(stats.counts_by_component).toEqual({}); + expect(stats.counts_by_status_code).toEqual({}); + }); + }); + + describe('trim', () => { + it('should remove old metrics data', () => { + jest.useFakeTimers('modern'); + jest.setSystemTime(0); + setup.addMetric('component1', 'action1', 200, 100); + jest.advanceTimersByTime(60000); + setup.addMetric('component1', 'action1', 200, 200); + jest.advanceTimersByTime(60000); + setup.addMetric('component1', 'action1', 200, 300); + jest.advanceTimersByTime(60000); + metricsService.trim(); + jest.setSystemTime(0); + const stats = setup.getStats(); + expect(stats.data).toEqual({}); + expect(stats.overall).toEqual({ response_time_avg: 0, requests_per_second: 0 }); + expect(stats.counts_by_component).toEqual({}); + expect(stats.counts_by_status_code).toEqual({}); + }); + }); +}); diff --git a/server/metrics/metrics_service.ts b/server/metrics/metrics_service.ts new file mode 100644 index 0000000..adfb101 --- /dev/null +++ b/server/metrics/metrics_service.ts @@ -0,0 +1,155 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger } from '../../../../src/core/server'; +import { METRIC_INTERVAL, DEFAULT_WINDOW_SIZE } from '.'; + +interface MetricValue { + response_time_total: number; + count: number; +} + +interface MetricOutput { + response_time_avg: number; + requests_per_second: number; +} + +interface MetricsData { + [componentName: string]: { + [action: string]: { + [statusCode: number]: MetricValue; + }; + }; +} + +export interface Stats { + readonly data: MetricsData; + readonly overall: MetricOutput; + readonly counts_by_component: Record; + readonly counts_by_status_code: Record; +} + +export interface MetricsServiceSetup { + addMetric: (componentName: string, action: string, statusCode: number, value: number) => void; + getStats: () => Stats; +} + +export class MetricsService { + private interval: number = METRIC_INTERVAL.ONE_MINUTE; + private windowSize: number = DEFAULT_WINDOW_SIZE; + + private data: Record = {}; + private componentCounts: Record> = {}; + private statusCodeCounts: Record> = {}; + private overall: Record = {}; + + constructor(private logger?: Logger) {} + + setup( + interval: number = METRIC_INTERVAL.ONE_MINUTE, + windowSize: number = DEFAULT_WINDOW_SIZE + ): MetricsServiceSetup { + this.interval = interval; + this.windowSize = windowSize; + + const addMetric = ( + componentName: string, + action: string, + statusCode: number, + value: number + ): void => { + const currInterval = Math.floor(Date.now() / this.interval); + + this.trim(); + + if (!this.data[currInterval]) { + this.data[currInterval] = {}; + this.overall[currInterval] = { response_time_total: 0, count: 0 }; + this.componentCounts[currInterval] = {}; + this.statusCodeCounts[currInterval] = {}; + } + + if (!this.data[currInterval][componentName]) { + this.data[currInterval][componentName] = {}; + this.componentCounts[currInterval][componentName] = 0; + } + + if (!this.statusCodeCounts[currInterval][statusCode]) { + this.statusCodeCounts[currInterval][statusCode] = 0; + } + + if (!this.data[currInterval][componentName][action]) { + this.data[currInterval][componentName][action] = {}; + } + + if (!this.data[currInterval][componentName][action][statusCode]) { + this.data[currInterval][componentName][action][statusCode] = { response_time_total: 0, count: 0 }; + } + + const { response_time_total, count } = this.data[currInterval][componentName][action][statusCode]; + this.data[currInterval][componentName][action][statusCode] = { + response_time_total: response_time_total + value, + count: count + 1, + }; + + this.componentCounts[currInterval][componentName]++; + this.statusCodeCounts[currInterval][statusCode]++; + + this.overall[currInterval].response_time_total += value; + this.overall[currInterval].count++; + }; + + const getStats = (): Stats => { + const prevInterval = Math.floor(Date.now() / this.interval) - 1; + const data = { ...this.data[prevInterval] } || {}; + const overall = { ...this.overall[prevInterval] } || {}; + + let requestsPerSecond = 0, + responseTimeAvg = 0; + + if (Object.keys(overall).length !== 0 && overall.count !== 0) { + responseTimeAvg = overall.response_time_total / overall.count; + requestsPerSecond = overall.count / (this.interval / 1000); + } + + return { + data, + overall: { + response_time_avg: responseTimeAvg, + requests_per_second: requestsPerSecond, + }, + counts_by_component: { ...this.componentCounts[prevInterval] } || {}, + counts_by_status_code: { ...this.statusCodeCounts[prevInterval] } || {}, + }; + }; + + return { addMetric, getStats }; + } + + start() {} + + stop() { + this.resetMetrics(); + } + + resetMetrics(): void { + this.data = {}; + this.overall = {}; + this.componentCounts = {}; + this.statusCodeCounts = {}; + } + + trim(): void { + const oldestTimestampToKeep = Math.floor( + (Date.now() - this.windowSize * this.interval) / this.interval + ); + for (const timestampStr in this.data) { + const timestamp = parseInt(timestampStr); + if (timestamp < oldestTimestampToKeep) { + delete this.data[timestamp]; + } + } + } +} diff --git a/server/plugin.ts b/server/plugin.ts index a8b9f16..2e58b48 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -3,43 +3,64 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; + import { PluginInitializerContext, CoreSetup, CoreStart, + IContextProvider, Logger, Plugin, ILegacyClusterClient, + RequestHandler, } from '../../../src/core/server'; import { defineRoutes } from './routes'; +import { MetricsService, MetricsServiceSetup } from './metrics/metrics_service'; +import { SearchRelevancePluginConfigType } from '../config'; import { SearchRelevancePluginSetup, SearchRelevancePluginStart } from './types'; export class SearchRelevancePlugin - implements Plugin { + implements Plugin +{ + private readonly config$: Observable; private readonly logger: Logger; - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); + private metricsService: MetricsService; + + constructor(private initializerContext: PluginInitializerContext) { + this.config$ = this.initializerContext.config.create(); + this.logger = this.initializerContext.logger.get(); + this.metricsService = new MetricsService(this.logger.get('metrics-service')); } - public setup(core: CoreSetup) { + public async setup(core: CoreSetup) { this.logger.debug('SearchRelevance: Setup'); - const router = core.http.createRouter(); - const opensearchSearchRelevanceClient: ILegacyClusterClient = core.opensearch.legacy.createClient( - 'opensearch_search_relevance' + const config: SearchRelevancePluginConfigType = await this.config$.pipe(first()).toPromise(); + + const metricsService: MetricsServiceSetup = this.metricsService.setup( + config.metrics.metricInterval, + config.metrics.windowSize ); + const router = core.http.createRouter(); + + const opensearchSearchRelevanceClient: ILegacyClusterClient = + core.opensearch.legacy.createClient('opensearch_search_relevance'); + // @ts-ignore - core.http.registerRouteHandlerContext('search_relevance_plugin', (context, request) => { + core.http.registerRouteHandlerContext('searchRelevance', (context, request) => { return { logger: this.logger, relevancyWorkbenchClient: opensearchSearchRelevanceClient, + metricsService: metricsService, }; }); // Register server side APIs - defineRoutes({ router }); + defineRoutes(router); return {}; } diff --git a/server/routes/dsl_route.ts b/server/routes/dsl_route.ts index b5d566c..bd64a8b 100644 --- a/server/routes/dsl_route.ts +++ b/server/routes/dsl_route.ts @@ -7,40 +7,118 @@ import { schema } from '@osd/config-schema'; import { RequestParams } from '@opensearch-project/opensearch'; import { IRouter } from '../../../../src/core/server'; +import { METRIC_NAME, METRIC_ACTION } from '../metrics'; import { ServiceEndpoints } from '../../common'; -export function registerDslRoute({ router }: { router: IRouter }) { +interface SearchResultsResponse { + result1?: Object; + result2?: Object; + errorMessage1?: Object; + errorMessage2?: Object; +} + +const performance = require('perf_hooks').performance; + +export function registerDslRoute(router: IRouter) { router.post( { path: ServiceEndpoints.GetSearchResults, validate: { body: schema.any() }, }, async (context, request, response) => { - const { index, size, ...rest } = request.body; - const params: RequestParams.Search = { - index, - size, - body: rest, - }; - try { - const resp = await context.core.opensearch.legacy.client.callAsCurrentUser( - 'search', - params - ); - return response.ok({ - body: resp, - }); - } catch (error) { - if (error.statusCode !== 404) console.error(error); + const { query1, query2 } = request.body; + const actionName = + query1 && query2 ? METRIC_ACTION.COMPARISON_SEARCH : METRIC_ACTION.SINGLE_SEARCH; + let resBody: SearchResultsResponse = {}; - // Template: Error: {{Error.type}} ā€“ {{Error.reason}} - const errorMessage = `Error: ${error.body?.error?.type} - ${error.body?.error?.reason}`; + if (query1) { + const { index, size, ...rest } = query1; + const params: RequestParams.Search = { + index, + size, + body: rest, + }; - return response.custom({ - statusCode: error.statusCode || 500, - body: errorMessage, - }); + const start = performance.now(); + try { + const resp = await context.core.opensearch.legacy.client.callAsCurrentUser( + 'search', + params + ); + const end = performance.now(); + context.searchRelevance.metricsService.addMetric( + METRIC_NAME.SEARCH_RELEVANCE, + actionName, + 200, + end - start + ); + resBody.result1 = resp; + } catch (error) { + const end = performance.now(); + context.searchRelevance.metricsService.addMetric( + METRIC_NAME.SEARCH_RELEVANCE, + actionName, + error.statusCode, + end - start + ); + + if (error.statusCode !== 404) console.error(error); + + // Template: Error: {{Error.type}} ā€“ {{Error.reason}} + const errorMessage = `Error: ${error.body?.error?.type} - ${error.body?.error?.reason}`; + + resBody.errorMessage1 = { + statusCode: error.statusCode || 500, + body: errorMessage, + }; + } } + + if (query2) { + const { index, size, ...rest } = query2; + const params: RequestParams.Search = { + index, + size, + body: rest, + }; + + const start = performance.now(); + try { + const resp = await context.core.opensearch.legacy.client.callAsCurrentUser( + 'search', + params + ); + const end = performance.now(); + context.searchRelevance.metricsService.addMetric( + METRIC_NAME.SEARCH_RELEVANCE, + actionName, + 200, + end - start + ); + resBody.result2 = resp; + } catch (error) { + const end = performance.now(); + if (error.statusCode !== 404) console.error(error); + context.searchRelevance.metricsService.addMetric( + METRIC_NAME.SEARCH_RELEVANCE, + actionName, + error.statusCode, + end - start + ); + + // Template: Error: {{Error.type}} ā€“ {{Error.reason}} + const errorMessage = `Error: ${error.body?.error?.type} - ${error.body?.error?.reason}`; + + resBody.errorMessage2 = { + statusCode: error.statusCode || 500, + body: errorMessage, + }; + } + } + + return response.ok({ + body: resBody, + }); } ); @@ -53,15 +131,30 @@ export function registerDslRoute({ router }: { router: IRouter }) { const params = { format: 'json', }; + const start = performance.now(); try { const resp = await context.core.opensearch.legacy.client.callAsCurrentUser( 'cat.indices', params ); + const end = performance.now(); + context.searchRelevance.metricsService.addMetric( + METRIC_NAME.SEARCH_RELEVANCE, + METRIC_ACTION.FETCH_INDEX, + 200, + end - start + ); return response.ok({ body: resp, }); } catch (error) { + const end = performance.now(); + context.searchRelevance.metricsService.addMetric( + METRIC_NAME.SEARCH_RELEVANCE, + METRIC_ACTION.FETCH_INDEX, + error.statusCode, + end - start + ); if (error.statusCode !== 404) console.error(error); return response.custom({ statusCode: error.statusCode || 500, diff --git a/server/routes/index.ts b/server/routes/index.ts index 7f09123..aae28f4 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -5,7 +5,9 @@ import { ILegacyClusterClient, IRouter } from '../../../../src/core/server'; import { registerDslRoute } from './dsl_route'; +import { registerMetricsRoute } from './metrics_route'; -export function defineRoutes({ router }: { router: IRouter }) { - registerDslRoute({ router }); +export function defineRoutes(router: IRouter) { + registerDslRoute(router); + registerMetricsRoute(router); } diff --git a/server/routes/metrics_route.ts b/server/routes/metrics_route.ts new file mode 100644 index 0000000..0c903e7 --- /dev/null +++ b/server/routes/metrics_route.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IRouter } from '../../../../src/core/server'; +import { ServiceEndpoints } from '../../common'; + +export function registerMetricsRoute(router: IRouter) { + router.get( + { + path: ServiceEndpoints.GetStats, + validate: false, + }, + async (context, _, response) => { + try { + const metrics = context.searchRelevance.metricsService.getStats(); + return response.ok({ + body: JSON.stringify(metrics, null, 2), + }); + } catch (error) { + console.error(error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); +} diff --git a/server/types.ts b/server/types.ts index 0acae3e..6fa37d1 100644 --- a/server/types.ts +++ b/server/types.ts @@ -3,6 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { MetricsServiceSetup } from './metrics/metrics_service'; + +export interface SearchRelevancePluginRequestContext { + metricsService: MetricsServiceSetup; +} + +declare module '../../../src/core/server' { + interface RequestHandlerContext { + searchRelevance: SearchRelevancePluginRequestContext; + } +} + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SearchRelevancePluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/tsconfig.json b/tsconfig.json index 7f7c24e..74128ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "public/**/*.tsx", "server/**/*.ts", "common/**/*.ts", - "../../typings/**/*" + "../../typings/**/*", + "config.ts" ], "exclude": ["node_modules", "*/node_modules/"] }