From 1d06e30a28b9058dd24ca4d17f4333e8a0ba94df Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 17 Jun 2025 20:30:25 +0200 Subject: [PATCH] demo: prometheus --- .../createImpactMetricsService.ts | 10 ++ .../impact-metrics-controller.ts | 67 ++++++++ .../impact-metrics/impact-metrics-service.ts | 155 ++++++++++++++++++ src/lib/openapi/spec/impact-metrics-schema.ts | 15 ++ src/lib/openapi/spec/index.ts | 1 + src/lib/routes/admin-api/index.ts | 5 + src/lib/services/index.ts | 11 ++ src/lib/types/experimental.ts | 4 + 8 files changed, 268 insertions(+) create mode 100644 src/lib/features/impact-metrics/createImpactMetricsService.ts create mode 100644 src/lib/features/impact-metrics/impact-metrics-controller.ts create mode 100644 src/lib/features/impact-metrics/impact-metrics-service.ts create mode 100644 src/lib/openapi/spec/impact-metrics-schema.ts diff --git a/src/lib/features/impact-metrics/createImpactMetricsService.ts b/src/lib/features/impact-metrics/createImpactMetricsService.ts new file mode 100644 index 0000000000..457a0b79b9 --- /dev/null +++ b/src/lib/features/impact-metrics/createImpactMetricsService.ts @@ -0,0 +1,10 @@ +import type { IUnleashConfig } from '../../types/index.js'; +import ImpactMetricsService from './impact-metrics-service.js'; + +export const createImpactMetricsService = (config: IUnleashConfig) => { + return new ImpactMetricsService(config); +}; + +export const createFakeImpactMetricsService = (config: IUnleashConfig) => { + return new ImpactMetricsService(config); +}; diff --git a/src/lib/features/impact-metrics/impact-metrics-controller.ts b/src/lib/features/impact-metrics/impact-metrics-controller.ts new file mode 100644 index 0000000000..bc416ec703 --- /dev/null +++ b/src/lib/features/impact-metrics/impact-metrics-controller.ts @@ -0,0 +1,67 @@ +import type { Response } from 'express'; +import Controller from '../../routes/controller.js'; +import type { IAuthRequest } from '../../routes/unleash-types.js'; +import type { IUnleashConfig } from '../../types/option.js'; +import { NONE } from '../../types/permissions.js'; +import type { OpenApiService } from '../../services/openapi-service.js'; +import { getStandardResponses } from '../../openapi/util/standard-responses.js'; +import { createResponseSchema } from '../../openapi/util/create-response-schema.js'; +import type ImpactMetricsService from './impact-metrics-service.js'; +import { + impactMetricsSchema, + type ImpactMetricsSchema, +} from '../../openapi/spec/impact-metrics-schema.js'; + +interface ImpactMetricsServices { + impactMetricsService: ImpactMetricsService; + openApiService: OpenApiService; +} + +export default class ImpactMetricsController extends Controller { + private impactMetricsService: ImpactMetricsService; + private openApiService: OpenApiService; + + constructor( + config: IUnleashConfig, + { impactMetricsService, openApiService }: ImpactMetricsServices, + ) { + super(config); + this.impactMetricsService = impactMetricsService; + this.openApiService = openApiService; + + this.route({ + method: 'get', + path: '', + handler: this.getImpactMetrics, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getImpactMetrics', + summary: 'Get impact metrics', + description: + 'Retrieves impact metrics data from Prometheus and forwards it to the frontend.', + responses: { + 200: createResponseSchema('impactMetricsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + } + + async getImpactMetrics( + req: IAuthRequest, + res: Response, + ): Promise { + const metrics = + await this.impactMetricsService.getMetricsFromPrometheus(); + + this.openApiService.respondWithValidation( + 200, + res, + impactMetricsSchema.$id, + metrics, + ); + } +} diff --git a/src/lib/features/impact-metrics/impact-metrics-service.ts b/src/lib/features/impact-metrics/impact-metrics-service.ts new file mode 100644 index 0000000000..fa57886558 --- /dev/null +++ b/src/lib/features/impact-metrics/impact-metrics-service.ts @@ -0,0 +1,155 @@ +import type { Logger } from '../../logger.js'; +import type { IUnleashConfig } from '../../types/index.js'; + +interface PrometheusResponse { + status: string; + data: any; +} + +interface MetricSeries { + name: string; + available: boolean; + labels: Record; +} + +export interface IImpactMetricsService { + getMetricsFromPrometheus(): Promise<{ + availableMetrics: MetricSeries[]; + totalCount: number; + }>; +} + +// TODO: URL +const prometheusUrl = 'http://localhost:9090'; + +export default class ImpactMetricsService implements IImpactMetricsService { + private logger: Logger; + private config: IUnleashConfig; + + constructor(config: IUnleashConfig) { + this.logger = config.getLogger( + 'impact-metrics/impact-metrics-service.ts', + ); + this.config = config; + } + + async getMetricsFromPrometheus(): Promise<{ + availableMetrics: MetricSeries[]; + totalCount: number; + }> { + const testingMetrics = [ + 'node_cpu_seconds_total', + 'node_load5', + 'node_memory_MemAvailable_bytes', + 'node_disk_read_bytes_total', + 'node_disk_written_bytes_total', + 'node_network_receive_bytes_total', + 'node_network_transmit_bytes_total', + 'node_filesystem_avail_bytes', + 'node_filesystem_size_bytes', + ]; + + try { + this.logger.info('Fetching all metrics from Prometheus'); + + const metricsResponse = await fetch( + `${prometheusUrl}/api/v1/label/__name__/values`, + ); + + if (!metricsResponse.ok) { + throw new Error( + `Failed to fetch metrics: ${metricsResponse.status}`, + ); + } + + const metricsData = + (await metricsResponse.json()) as PrometheusResponse; + if (metricsData.status !== 'success') { + throw new Error('Prometheus API returned error status'); + } + + const allMetrics: string[] = metricsData.data; + this.logger.info( + `Found ${allMetrics.length} total metrics in Prometheus`, + ); + + const availableMetrics: MetricSeries[] = []; + + for (const metricName of testingMetrics) { + const isAvailable = allMetrics.includes(metricName); + + let labels: Record = {}; + + if (isAvailable) { + try { + const seriesUrl = `${prometheusUrl}/api/v1/series?match[]=${encodeURIComponent(metricName)}`; + this.logger.debug( + `Fetching series for metric: ${metricName}`, + ); + + const seriesResponse = await fetch(seriesUrl); + const seriesData = + (await seriesResponse.json()) as PrometheusResponse; + if (seriesData.status === 'success') { + const labelMap: Record> = {}; + + seriesData.data.forEach( + (series: Record) => { + Object.entries(series).forEach( + ([key, value]) => { + if (key !== '__name__') { + if (!labelMap[key]) { + labelMap[key] = new Set(); + } + labelMap[key].add(value); + } + }, + ); + }, + ); + + labels = Object.fromEntries( + Object.entries(labelMap).map( + ([key, valueSet]) => [ + key, + Array.from(valueSet).sort(), + ], + ), + ); + } + } catch (error) { + this.logger.warn( + `Failed to fetch labels for metric ${metricName}:`, + error, + ); + } + } + + availableMetrics.push({ + name: metricName, + available: isAvailable, + labels, + }); + + this.logger.debug( + `Metric ${metricName}: available=${isAvailable}, labelCount=${Object.keys(labels).length}`, + ); + } + + const availableCount = availableMetrics.filter( + (m) => m.available, + ).length; + this.logger.info( + `Found ${availableCount}/${testingMetrics.length} test metrics available in Prometheus`, + ); + + return { + availableMetrics, + totalCount: allMetrics.length, + }; + } catch (error) { + this.logger.error('Error fetching metrics from Prometheus:', error); + throw error; + } + } +} diff --git a/src/lib/openapi/spec/impact-metrics-schema.ts b/src/lib/openapi/spec/impact-metrics-schema.ts new file mode 100644 index 0000000000..6b877dfec1 --- /dev/null +++ b/src/lib/openapi/spec/impact-metrics-schema.ts @@ -0,0 +1,15 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const impactMetricsSchema = { + $id: '#/components/schemas/impactMetricsSchema', + type: 'object', + description: 'Impact metrics data from Prometheus', + additionalProperties: true, + required: [], + properties: {}, + components: { + schemas: {}, + }, +} as const; + +export type ImpactMetricsSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 04771b2f0e..6e44c2ba4b 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -113,6 +113,7 @@ export * from './health-overview-schema.js'; export * from './health-report-schema.js'; export * from './id-schema.js'; export * from './ids-schema.js'; +export * from './impact-metrics-schema.js'; export * from './import-toggles-schema.js'; export * from './import-toggles-validate-item-schema.js'; export * from './import-toggles-validate-schema.js'; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 5e966f699c..f26d4a0771 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -35,6 +35,7 @@ import { InactiveUsersController } from '../../users/inactive/inactive-users-con import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller.js'; import { SearchApi } from './search/index.js'; import PersonalDashboardController from '../../features/personal-dashboard/personal-dashboard-controller.js'; +import ImpactMetricsController from '../../features/impact-metrics/impact-metrics-controller.js'; import FeatureLifecycleCountController from '../../features/feature-lifecycle/feature-lifecycle-count-controller.js'; import type { IUnleashServices } from '../../services/index.js'; import CustomMetricsController from '../../features/metrics/custom/custom-metrics-controller.js'; @@ -137,6 +138,10 @@ export class AdminApi extends Controller { '/personal-dashboard', new PersonalDashboardController(config, services).router, ); + this.app.use( + '/impact-metrics', + new ImpactMetricsController(config, services).router, + ); this.app.use( '/environments', new EnvironmentsController(config, services).router, diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 0f80b77f38..19aaf35332 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -171,6 +171,11 @@ import type { import type { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType.js'; import { UnknownFlagsService } from '../features/metrics/unknown-flags/unknown-flags-service.js'; import type FeatureLinkService from '../features/feature-links/feature-link-service.js'; +import { + createFakeImpactMetricsService, + createImpactMetricsService, +} from '../features/impact-metrics/createImpactMetricsService.js'; +import type ImpactMetricsService from '../features/impact-metrics/impact-metrics-service.js'; export const createServices = ( stores: IUnleashStores, @@ -441,6 +446,10 @@ export const createServices = ( ? withTransactional(createUserSubscriptionsService(config), db) : withFakeTransactional(createFakeUserSubscriptionsService(config)); + const impactMetricsService = db + ? createImpactMetricsService(config) + : createFakeImpactMetricsService(config); + return { transactionalAccessService, accessService, @@ -507,6 +516,7 @@ export const createServices = ( integrationEventsService, onboardingService, personalDashboardService, + impactMetricsService, projectStatusService, transactionalUserSubscriptionsService, uniqueConnectionService, @@ -638,6 +648,7 @@ export interface IUnleashServices { integrationEventsService: IntegrationEventsService; onboardingService: OnboardingService; personalDashboardService: PersonalDashboardService; + impactMetricsService: ImpactMetricsService; projectStatusService: ProjectStatusService; transactionalUserSubscriptionsService: WithTransactional; uniqueConnectionService: UniqueConnectionService; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 41111f6c4a..cc3a22d432 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -292,6 +292,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_IMPROVED_JSON_DIFF, false, ), + impactMetrics: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_IMPACT_METRICS, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = {