diff --git a/.changeset/ninety-corners-flash.md b/.changeset/ninety-corners-flash.md new file mode 100644 index 0000000000..f858ec0593 --- /dev/null +++ b/.changeset/ninety-corners-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Migrates existing catalog metrics to use the alpha MetricsService. This release is a 1:1 migration with no breaking changes. diff --git a/.changeset/rare-adults-attack.md b/.changeset/rare-adults-attack.md new file mode 100644 index 0000000000..8117f26dea --- /dev/null +++ b/.changeset/rare-adults-attack.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-defaults': patch +--- + +Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins. diff --git a/.changeset/twenty-worlds-create.md b/.changeset/twenty-worlds-create.md new file mode 100644 index 0000000000..e7e4770938 --- /dev/null +++ b/.changeset/twenty-worlds-create.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Adds a new metrics service mock to be leveraged in tests diff --git a/beps/0012-metrics-service/README.md b/beps/0012-metrics-service/README.md index 59096d11d8..87b2059f02 100644 --- a/beps/0012-metrics-service/README.md +++ b/beps/0012-metrics-service/README.md @@ -23,7 +23,6 @@ creation-date: 2025-06-23 - [Integration with OpenTelemetry Auto-Instrumentation](#integration-with-opentelemetry-auto-instrumentation) - [Configuration](#configuration) - [Interface](#interface) - - [Root Metrics Service](#root-metrics-service) - [Plugin Metrics Service](#plugin-metrics-service) - [Example](#example) - [Release Plan](#release-plan) @@ -36,7 +35,7 @@ Add a core `MetricsService` to Backstage's framework to provide a unified interf ## Motivation -While individual plugins may implement their own metrics, there's no standardized approach leading to inconsistent metrics patterns across the ecosystem. For example, both `catalog_entities_count` and `catalog.processed.entities.count` are examples of existing metric patterns. Ideally, these would be standardized to `backstage.plugin.catalog.entities.count` and `backstage.plugin.catalog.entities.processed.total` respectively. +While individual plugins may implement their own metrics, there's no standardized approach leading to inconsistent metrics patterns across the ecosystem and incompatibility with OpenTelemetry semantic conventions. For example, a plugin implementing MCP functionality might incorrectly namespace metrics as `backstage_mcp_client_duration` when OpenTelemetry semantic conventions explicitly define `mcp.client.operation.duration` as the standard. By providing a core metrics service: @@ -45,7 +44,7 @@ By providing a core metrics service: ### Goals -- Plugin-scoped metric namespacing +- Plugin identification via OpenTelemetry Instrumentation Scope - Consistent metrics patterns across all plugins - Aligned with OpenTelemetry industry standards - Provide a familiar interface as other core services @@ -124,9 +123,12 @@ The `MetricsService` **complements** rather than duplicates auto-instrumentation // MetricsService provides (manually): const entityMetrics = metricsService.createCounter('entities.processed.total'); -entityMetrics.add(entities.length, { operation: 'refresh', kind: 'Component' }); +entityMetrics.add(entities.length, { + operation: 'refresh', + 'entity.kind': 'Component', +}); -// Metric is now available as `backstage.plugin.catalog.entities.processed.total` +// Metric is now available as `entities.processed.total` ``` ### Configuration @@ -162,43 +164,21 @@ interface MetricsService { } ``` -#### Root Metrics Service - -The `RootMetricsService` is responsible for providing metrics to other root services and creating both plugin-scoped and core-scoped `MetricsService` instances. - -```ts -interface RootMetricsService { - // note: no config is provided to the root service. - static forRoot(): RootMetricsService; - forPlugin(pluginId: string): MetricsService; - - // final implementation will be similar to - forService(serviceName: string, scope: 'plugin' | 'core'): MetricsService; -} - -export const rootMetricsServiceFactory = createServiceFactory({ - // depends on as little as possible so that it can be initialized as early as possible. - service: rootMetricsServiceRef, - deps: {}, - factory: () => { - return DefaultRootMetricsService.forRoot(); - }, -}); -``` - #### Plugin Metrics Service -Each plugin receives a metrics service that automatically namespaces all metrics to match the naming conventions. +Each plugin receives a metrics service that automatically configures the Instrumentation Scope to identify the plugin. The scope name follows the pattern `backstage-plugin-{pluginId}`. ```ts -const metricsServiceFactory = createServiceFactory({ - service: metricsServiceRef, +export const metricsServiceFactory = createServiceFactory({ + service: coreServices.metrics, deps: { - rootMetrics: coreServices.rootMetrics, pluginMetadata: coreServices.pluginMetadata, }, - factory: ({ rootMetrics, pluginMetadata }) => { - return rootMetrics.forPlugin(pluginMetadata.getId()); + factory: ({ pluginMetadata }) => { + const pluginId = pluginMetadata.getId(); + const scopeName = `backstage-plugin-${pluginId}`; + + return new DefaultMetricsService(scopeName, version, ...); }, }); ``` @@ -248,3 +228,22 @@ entitiesProcessed.add(100); - Plugin authors continue to implement their own metrics as they see fit. - A combined TelemetryService that provides both metrics and tracing. + +### Rejected: Forced Namespace Prefixes + +Prepend `backstage.plugin.{pluginId}.` to all metric names. This was the original proposal but conflicts with OpenTelemetry semantic conventions. + +**Problems:** + +- Makes it impossible to use standard semantic conventions like `mcp.*`, `gen_ai.*`, `http.*` +- Breaks compatibility with industry-standard observability tooling +- Prevents cross-service metric aggregation +- Goes against OpenTelemetry best practices and official guidance + +**Example of conflict:** + +```ts +// Plugin wants to emit: mcp.client.operation.duration +// Framework forces: backstage.plugin.mcp-actions.mcp.client.operation.duration +// This violates the semantic convention and breaks tooling +``` diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 64aabe5cc8..3d09146b0d 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -1127,6 +1127,36 @@ export interface Config { headers?: { [name: string]: string }; }; + /** + * Options for the metrics service. + */ + metrics?: { + /** + * Plugin-specific metrics configuration. Each plugin can override meter metadata. + */ + plugin?: { + [pluginId: string]: { + /** + * Meter configuration for this plugin. + */ + meter?: { + /** + * Custom meter name. If not set, defaults to backstage-plugin-{pluginId}. + */ + name?: string; + /** + * Version for the meter. + */ + version?: string; + /** + * Schema URL for the meter. + */ + schemaUrl?: string; + }; + }; + }; + }; + /** * Options to configure the default RootLoggerService. */ diff --git a/packages/backend-defaults/report-alpha.api.md b/packages/backend-defaults/report-alpha.api.md index c40517863a..352ec1d688 100644 --- a/packages/backend-defaults/report-alpha.api.md +++ b/packages/backend-defaults/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { ActionsService } from '@backstage/backend-plugin-api/alpha'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; import { RootSystemMetadataService } from '@backstage/backend-plugin-api/alpha'; import { ServiceFactory } from '@backstage/backend-plugin-api'; @@ -22,6 +23,13 @@ export const actionsServiceFactory: ServiceFactory< 'singleton' >; +// @alpha +export const metricsServiceFactory: ServiceFactory< + MetricsService, + 'plugin', + 'singleton' +>; + // @alpha export const rootSystemMetadataServiceFactory: ServiceFactory< RootSystemMetadataService, diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 9f8116a996..2b4ee5befa 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -38,6 +38,7 @@ import { eventsServiceFactory } from '@backstage/plugin-events-node'; import { actionsRegistryServiceFactory, actionsServiceFactory, + metricsServiceFactory, } from '@backstage/backend-defaults/alpha'; import { instanceMetadataServiceFactory } from './alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory'; @@ -66,6 +67,7 @@ export const defaultServiceFactories = [ // alpha services actionsRegistryServiceFactory, actionsServiceFactory, + metricsServiceFactory, // Unexported alpha services kept around for compatibility reasons instanceMetadataServiceFactory, diff --git a/packages/backend-defaults/src/alpha/entrypoints/metrics/DefaultMetricsService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/metrics/DefaultMetricsService.test.ts new file mode 100644 index 0000000000..75e5104754 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/metrics/DefaultMetricsService.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { metrics } from '@opentelemetry/api'; +import { DefaultMetricsService } from './DefaultMetricsService'; + +const mockGetMeter = jest.spyOn(metrics, 'getMeter'); + +describe('DefaultMetricsService', () => { + beforeEach(() => { + mockGetMeter.mockClear(); + }); + + describe('create', () => { + it('should create a MetricsService with name only', () => { + const service = DefaultMetricsService.create({ name: 'test-meter' }); + + expect(mockGetMeter).toHaveBeenCalledTimes(1); + expect(mockGetMeter).toHaveBeenCalledWith('test-meter', undefined, { + schemaUrl: undefined, + }); + + expect(service).toBeDefined(); + }); + + it('should create a MetricsService with name, version, and schemaUrl', () => { + const service = DefaultMetricsService.create({ + name: 'test-meter', + version: '1.2.3', + schemaUrl: 'https://example.com/schema', + }); + + expect(mockGetMeter).toHaveBeenCalledTimes(1); + expect(mockGetMeter).toHaveBeenCalledWith('test-meter', '1.2.3', { + schemaUrl: 'https://example.com/schema', + }); + + expect(service).toBeDefined(); + }); + }); + + describe('metric instruments', () => { + it('should create a counter', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const counter = service.createCounter('my_counter', { + description: 'A test counter', + unit: 'bytes', + }); + + expect(counter).toBeDefined(); + expect(counter.add).toBeDefined(); + }); + + it('should create an up-down counter', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const upDownCounter = service.createUpDownCounter('my_updown'); + + expect(upDownCounter).toBeDefined(); + expect(upDownCounter.add).toBeDefined(); + }); + + it('should create a histogram', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const histogram = service.createHistogram('my_histogram'); + + expect(histogram).toBeDefined(); + expect(histogram.record).toBeDefined(); + }); + + it('should create a gauge', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const gauge = service.createGauge('my_gauge'); + + expect(gauge).toBeDefined(); + expect(gauge.record).toBeDefined(); + }); + + it('should create an observable counter', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const counter = service.createObservableCounter('my_observable_counter'); + + expect(counter).toBeDefined(); + expect(counter.addCallback).toBeDefined(); + expect(counter.removeCallback).toBeDefined(); + }); + + it('should create an observable up-down counter', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const counter = service.createObservableUpDownCounter( + 'my_observable_updown', + ); + + expect(counter).toBeDefined(); + expect(counter.addCallback).toBeDefined(); + }); + + it('should create an observable gauge', () => { + const service = DefaultMetricsService.create({ name: 'test' }); + const gauge = service.createObservableGauge('my_observable_gauge'); + + expect(gauge).toBeDefined(); + expect(gauge.addCallback).toBeDefined(); + }); + }); +}); diff --git a/packages/backend-defaults/src/alpha/entrypoints/metrics/DefaultMetricsService.ts b/packages/backend-defaults/src/alpha/entrypoints/metrics/DefaultMetricsService.ts new file mode 100644 index 0000000000..764c6d6d1c --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/metrics/DefaultMetricsService.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Meter, metrics } from '@opentelemetry/api'; +import { + MetricsService, + MetricAttributes, + MetricOptions, + MetricsServiceCounter, + MetricsServiceUpDownCounter, + MetricsServiceHistogram, + MetricsServiceGauge, + MetricsServiceObservableCounter, + MetricsServiceObservableGauge, + MetricsServiceObservableUpDownCounter, +} from '@backstage/backend-plugin-api/alpha'; + +/** + * Options for creating a {@link DefaultMetricsService}. + * + * @alpha + */ +export interface DefaultMetricsServiceOptions { + name: string; + version?: string; + schemaUrl?: string; +} + +/** + * Default implementation of the {@link MetricsService} interface. + * + * This implementation provides a thin wrapper around the OpenTelemetry Meter API. + * + * @alpha + */ +export class DefaultMetricsService implements MetricsService { + private readonly meter: Meter; + + private constructor(opts: DefaultMetricsServiceOptions) { + // The meter name sets the OpenTelemetry Instrumentation Scope which identifies the source of metrics in telemetry backends. + this.meter = metrics.getMeter(opts.name, opts.version, { + schemaUrl: opts.schemaUrl, + }); + } + + /** + * Creates a new {@link MetricsService} instance. + * + * @param opts - Options for configuring the meter scope + * @returns A new MetricsService instance + */ + static create(opts: DefaultMetricsServiceOptions): MetricsService { + return new DefaultMetricsService(opts); + } + + createCounter( + name: string, + opts?: MetricOptions, + ): MetricsServiceCounter { + return this.meter.createCounter(name, opts); + } + + createUpDownCounter( + name: string, + opts?: MetricOptions, + ): MetricsServiceUpDownCounter { + return this.meter.createUpDownCounter(name, opts); + } + + createHistogram( + name: string, + opts?: MetricOptions, + ): MetricsServiceHistogram { + return this.meter.createHistogram(name, opts); + } + + createGauge( + name: string, + opts?: MetricOptions, + ): MetricsServiceGauge { + return this.meter.createGauge(name, opts); + } + + createObservableCounter< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableCounter { + return this.meter.createObservableCounter(name, opts); + } + + createObservableUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableUpDownCounter { + return this.meter.createObservableUpDownCounter(name, opts); + } + + createObservableGauge< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableGauge { + return this.meter.createObservableGauge(name, opts); + } +} diff --git a/packages/backend-defaults/src/alpha/entrypoints/metrics/index.ts b/packages/backend-defaults/src/alpha/entrypoints/metrics/index.ts new file mode 100644 index 0000000000..fc3e04a855 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/metrics/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { metricsServiceFactory } from './metricsServiceFactory'; diff --git a/packages/backend-defaults/src/alpha/entrypoints/metrics/metricsServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/metrics/metricsServiceFactory.test.ts new file mode 100644 index 0000000000..854fcfd389 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/metrics/metricsServiceFactory.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + mockServices, + ServiceFactoryTester, +} from '@backstage/backend-test-utils'; +import { metricsServiceFactory } from './metricsServiceFactory'; +import { DefaultMetricsService } from './DefaultMetricsService'; + +describe('metricsServiceFactory', () => { + let createSpy: jest.SpyInstance; + + beforeEach(() => { + createSpy = jest.spyOn(DefaultMetricsService, 'create'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const defaultServices = [ + mockServices.rootConfig.factory(), + metricsServiceFactory, + ]; + + it('should use backstage-plugin-{pluginId} as meter name when no config is set', async () => { + await ServiceFactoryTester.from(metricsServiceFactory, { + dependencies: defaultServices, + }).getSubject('my-plugin'); + + expect(createSpy).toHaveBeenCalledWith({ + name: 'backstage-plugin-my-plugin', + version: undefined, + schemaUrl: undefined, + }); + }); + + it('should use custom name from config', async () => { + await ServiceFactoryTester.from(metricsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + metrics: { + plugin: { + 'my-plugin': { + meter: { + name: 'custom-metrics-name', + }, + }, + }, + }, + }, + }, + }), + metricsServiceFactory, + ], + }).getSubject('my-plugin'); + + expect(createSpy).toHaveBeenCalledWith({ + name: 'custom-metrics-name', + version: undefined, + schemaUrl: undefined, + }); + }); + + it('should accept version and schemaUrl from config', async () => { + await ServiceFactoryTester.from(metricsServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + metrics: { + plugin: { + 'my-plugin': { + meter: { + name: 'my-plugin-metrics', + version: '1.2.3', + schemaUrl: 'https://example.com/schema', + }, + }, + }, + }, + }, + }, + }), + metricsServiceFactory, + ], + }).getSubject('my-plugin'); + + expect(createSpy).toHaveBeenCalledWith({ + name: 'my-plugin-metrics', + version: '1.2.3', + schemaUrl: 'https://example.com/schema', + }); + }); + + it('should implement the full MetricsService interface', async () => { + const subject = await ServiceFactoryTester.from(metricsServiceFactory, { + dependencies: defaultServices, + }).getSubject('test-plugin'); + + expect(createSpy).toHaveBeenCalledWith({ + name: 'backstage-plugin-test-plugin', + version: undefined, + schemaUrl: undefined, + }); + + expect(subject.createCounter).toBeDefined(); + expect(subject.createUpDownCounter).toBeDefined(); + expect(subject.createHistogram).toBeDefined(); + expect(subject.createGauge).toBeDefined(); + expect(subject.createObservableCounter).toBeDefined(); + expect(subject.createObservableUpDownCounter).toBeDefined(); + expect(subject.createObservableGauge).toBeDefined(); + }); +}); diff --git a/packages/backend-defaults/src/alpha/entrypoints/metrics/metricsServiceFactory.ts b/packages/backend-defaults/src/alpha/entrypoints/metrics/metricsServiceFactory.ts new file mode 100644 index 0000000000..a62ad0411a --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/metrics/metricsServiceFactory.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { metricsServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { DefaultMetricsService } from './DefaultMetricsService'; + +/** + * Service factory for collecting plugin-scoped metrics. + * + * @alpha + */ +export const metricsServiceFactory = createServiceFactory({ + service: metricsServiceRef, + deps: { + config: coreServices.rootConfig, + pluginMetadata: coreServices.pluginMetadata, + }, + factory: ({ config, pluginMetadata }) => { + const pluginId = pluginMetadata.getId(); + + const meterConfig = config.getOptionalConfig( + `backend.metrics.plugin.${pluginId}.meter`, + ); + const scopeName = `backstage-plugin-${pluginId}`; + const name = meterConfig?.getOptionalString('name') ?? scopeName; + const version = meterConfig?.getOptionalString('version'); + const schemaUrl = meterConfig?.getOptionalString('schemaUrl'); + + return DefaultMetricsService.create({ name, version, schemaUrl }); + }, +}); diff --git a/packages/backend-defaults/src/alpha/index.ts b/packages/backend-defaults/src/alpha/index.ts index c17e0e71cb..1d0ec158af 100644 --- a/packages/backend-defaults/src/alpha/index.ts +++ b/packages/backend-defaults/src/alpha/index.ts @@ -16,4 +16,5 @@ export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry'; export { actionsServiceFactory } from './entrypoints/actions'; +export { metricsServiceFactory } from './entrypoints/metrics'; export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata'; diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index e06e0c45d2..7d6786b97e 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -103,6 +103,150 @@ export const actionsServiceRef: ServiceRef< 'singleton' >; +// @alpha +export interface MetricAdvice { + explicitBucketBoundaries?: number[]; +} + +// @alpha +export interface MetricAttributes { + // (undocumented) + [attributeKey: string]: MetricAttributeValue | undefined; +} + +// @alpha +export type MetricAttributeValue = + | string + | number + | boolean + | Array + | Array + | Array; + +// @alpha +export interface MetricOptions { + advice?: MetricAdvice; + description?: string; + unit?: string; +} + +// @alpha +export interface MetricsService { + createCounter( + name: string, + opts?: MetricOptions, + ): MetricsServiceCounter; + createGauge( + name: string, + opts?: MetricOptions, + ): MetricsServiceGauge; + createHistogram( + name: string, + opts?: MetricOptions, + ): MetricsServiceHistogram; + createObservableCounter< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableCounter; + createObservableGauge< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableGauge; + createObservableUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableUpDownCounter; + createUpDownCounter( + name: string, + opts?: MetricOptions, + ): MetricsServiceUpDownCounter; +} + +// @alpha +export interface MetricsServiceCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> { + // (undocumented) + add(value: number, attributes?: TAttributes): void; +} + +// @alpha +export interface MetricsServiceGauge< + TAttributes extends MetricAttributes = MetricAttributes, +> { + // (undocumented) + record(value: number, attributes?: TAttributes): void; +} + +// @alpha +export interface MetricsServiceHistogram< + TAttributes extends MetricAttributes = MetricAttributes, +> { + // (undocumented) + record(value: number, attributes?: TAttributes): void; +} + +// @alpha +export interface MetricsServiceObservable< + TAttributes extends MetricAttributes = MetricAttributes, +> { + // (undocumented) + addCallback(callback: MetricsServiceObservableCallback): void; + // (undocumented) + removeCallback(callback: MetricsServiceObservableCallback): void; +} + +// @alpha +export type MetricsServiceObservableCallback< + TAttributes extends MetricAttributes = MetricAttributes, +> = ( + observableResult: MetricsServiceObservableResult, +) => void | Promise; + +// @alpha +export type MetricsServiceObservableCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> = MetricsServiceObservable; + +// @alpha +export type MetricsServiceObservableGauge< + TAttributes extends MetricAttributes = MetricAttributes, +> = MetricsServiceObservable; + +// @alpha +export interface MetricsServiceObservableResult< + TAttributes extends MetricAttributes = MetricAttributes, +> { + // (undocumented) + observe(value: number, attributes?: TAttributes): void; +} + +// @alpha +export type MetricsServiceObservableUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> = MetricsServiceObservable; + +// @alpha +export const metricsServiceRef: ServiceRef< + MetricsService, + 'plugin', + 'singleton' +>; + +// @alpha +export interface MetricsServiceUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> { + // (undocumented) + add(value: number, attributes?: TAttributes): void; +} + // @public (undocumented) export interface RootSystemMetadataService { // (undocumented) diff --git a/packages/backend-plugin-api/src/alpha/MetricsService.ts b/packages/backend-plugin-api/src/alpha/MetricsService.ts new file mode 100644 index 0000000000..1c92ea9e09 --- /dev/null +++ b/packages/backend-plugin-api/src/alpha/MetricsService.ts @@ -0,0 +1,273 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Attribute values that can be attached to metric measurements. + * + * @alpha + */ +export type MetricAttributeValue = + | string + | number + | boolean + | Array + | Array + | Array; + +/** + * A set of key-value pairs that can be attached to metric measurements. + * + * @alpha + */ +export interface MetricAttributes { + [attributeKey: string]: MetricAttributeValue | undefined; +} + +/** + * Advisory options that influence aggregation configuration. + * + * @alpha + */ +export interface MetricAdvice { + /** + * Hint the explicit bucket boundaries for histogram aggregation. + */ + explicitBucketBoundaries?: number[]; +} + +/** + * Options for creating a metric instrument. + * + * @alpha + */ +export interface MetricOptions { + /** + * The description of the Metric. + */ + description?: string; + /** + * The unit of the Metric values. + */ + unit?: string; + /** + * Advisory options that influence aggregation configuration. + */ + advice?: MetricAdvice; +} + +/** + * A counter metric that only supports non-negative increments. + * + * @alpha + */ +export interface MetricsServiceCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> { + add(value: number, attributes?: TAttributes): void; +} + +/** + * A counter metric that supports both positive and negative increments. + * + * @alpha + */ +export interface MetricsServiceUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> { + add(value: number, attributes?: TAttributes): void; +} + +/** + * A histogram metric for recording distributions of values. + * + * @alpha + */ +export interface MetricsServiceHistogram< + TAttributes extends MetricAttributes = MetricAttributes, +> { + record(value: number, attributes?: TAttributes): void; +} + +/** + * A gauge metric for recording instantaneous values. + * + * @alpha + */ +export interface MetricsServiceGauge< + TAttributes extends MetricAttributes = MetricAttributes, +> { + record(value: number, attributes?: TAttributes): void; +} + +/** + * The result object passed to observable metric callbacks. + * + * @alpha + */ +export interface MetricsServiceObservableResult< + TAttributes extends MetricAttributes = MetricAttributes, +> { + observe(value: number, attributes?: TAttributes): void; +} + +/** + * A callback function for observable metrics. Called whenever a metric + * collection is initiated. + * + * @alpha + */ +export type MetricsServiceObservableCallback< + TAttributes extends MetricAttributes = MetricAttributes, +> = ( + observableResult: MetricsServiceObservableResult, +) => void | Promise; + +/** + * An observable metric instrument that reports values via callbacks. + * + * @alpha + */ +export interface MetricsServiceObservable< + TAttributes extends MetricAttributes = MetricAttributes, +> { + addCallback(callback: MetricsServiceObservableCallback): void; + removeCallback(callback: MetricsServiceObservableCallback): void; +} + +/** + * An observable counter metric that reports non-negative sums via callbacks. + * + * @alpha + */ +export type MetricsServiceObservableCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> = MetricsServiceObservable; + +/** + * An observable counter metric that reports sums that can go up or down + * via callbacks. + * + * @alpha + */ +export type MetricsServiceObservableUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, +> = MetricsServiceObservable; + +/** + * An observable gauge metric that reports instantaneous values via callbacks. + * + * @alpha + */ +export type MetricsServiceObservableGauge< + TAttributes extends MetricAttributes = MetricAttributes, +> = MetricsServiceObservable; + +/** + * A service that provides a facility for emitting metrics. + * + * @alpha + */ +export interface MetricsService { + /** + * Creates a new counter metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The counter metric. + */ + createCounter( + name: string, + opts?: MetricOptions, + ): MetricsServiceCounter; + + /** + * Creates a new up-down counter metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The up-down counter metric. + */ + createUpDownCounter( + name: string, + opts?: MetricOptions, + ): MetricsServiceUpDownCounter; + + /** + * Creates a new histogram metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The histogram metric. + */ + createHistogram( + name: string, + opts?: MetricOptions, + ): MetricsServiceHistogram; + + /** + * Creates a new gauge metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The gauge metric. + */ + createGauge( + name: string, + opts?: MetricOptions, + ): MetricsServiceGauge; + + /** + * Creates a new observable counter metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The observable counter metric. + */ + createObservableCounter< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableCounter; + + /** + * Creates a new observable up-down counter metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The observable up-down counter metric. + */ + createObservableUpDownCounter< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableUpDownCounter; + + /** + * Creates a new observable gauge metric. + * + * @param name - The name of the metric. + * @param opts - The options for the metric. + * @returns The observable gauge metric. + */ + createObservableGauge< + TAttributes extends MetricAttributes = MetricAttributes, + >( + name: string, + opts?: MetricOptions, + ): MetricsServiceObservableGauge; +} diff --git a/packages/backend-plugin-api/src/alpha/index.ts b/packages/backend-plugin-api/src/alpha/index.ts index 56f02f5275..c487686338 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -27,8 +27,27 @@ export type { export type { ActionsService, ActionsServiceAction } from './ActionsService'; +export type { + MetricsService, + MetricAdvice, + MetricAttributes, + MetricAttributeValue, + MetricOptions, + MetricsServiceCounter, + MetricsServiceUpDownCounter, + MetricsServiceHistogram, + MetricsServiceGauge, + MetricsServiceObservable, + MetricsServiceObservableCallback, + MetricsServiceObservableCounter, + MetricsServiceObservableGauge, + MetricsServiceObservableResult, + MetricsServiceObservableUpDownCounter, +} from './MetricsService'; + export { actionsRegistryServiceRef, actionsServiceRef, + metricsServiceRef, rootSystemMetadataServiceRef, } from './refs'; diff --git a/packages/backend-plugin-api/src/alpha/refs.ts b/packages/backend-plugin-api/src/alpha/refs.ts index a890271364..bd87ea8467 100644 --- a/packages/backend-plugin-api/src/alpha/refs.ts +++ b/packages/backend-plugin-api/src/alpha/refs.ts @@ -56,3 +56,14 @@ export const rootSystemMetadataServiceRef = createServiceRef< id: 'alpha.core.rootSystemMetadata', scope: 'root', }); + +/** + * Service for managing metrics. + * + * @alpha + */ +export const metricsServiceRef = createServiceRef< + import('./MetricsService').MetricsService +>({ + id: 'alpha.core.metrics', +}); diff --git a/packages/backend-test-utils/report-alpha.api.md b/packages/backend-test-utils/report-alpha.api.md index ca6f47191d..b6bf9e883d 100644 --- a/packages/backend-test-utils/report-alpha.api.md +++ b/packages/backend-test-utils/report-alpha.api.md @@ -12,6 +12,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; import { ServiceFactory } from '@backstage/backend-plugin-api'; // @alpha (undocumented) @@ -43,6 +44,16 @@ export namespace actionsServiceMock { ) => ServiceMock; } +// @alpha (undocumented) +export namespace metricsServiceMock { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; +} + // @alpha export class MockActionsRegistry implements ActionsRegistryService, ActionsService diff --git a/packages/backend-test-utils/src/alpha/services/MetricsServiceMock.ts b/packages/backend-test-utils/src/alpha/services/MetricsServiceMock.ts new file mode 100644 index 0000000000..301d47fb2e --- /dev/null +++ b/packages/backend-test-utils/src/alpha/services/MetricsServiceMock.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createServiceMock } from './alphaCreateServiceMock'; +import { + MetricsService, + metricsServiceRef, +} from '@backstage/backend-plugin-api/alpha'; +import { metricsServiceFactory } from '@backstage/backend-defaults/alpha'; + +/** + * @alpha + */ +export namespace metricsServiceMock { + export const factory = () => metricsServiceFactory; + + export const mock = createServiceMock( + metricsServiceRef, + () => ({ + createCounter: jest.fn().mockImplementation(() => ({ + add: jest.fn(), + })), + createUpDownCounter: jest.fn().mockImplementation(() => ({ + add: jest.fn(), + })), + createHistogram: jest.fn().mockImplementation(() => ({ + record: jest.fn(), + })), + createGauge: jest.fn().mockImplementation(() => ({ + record: jest.fn(), + })), + createObservableCounter: jest.fn().mockImplementation(() => ({ + addCallback: jest.fn(), + removeCallback: jest.fn(), + })), + createObservableUpDownCounter: jest.fn().mockImplementation(() => ({ + addCallback: jest.fn(), + removeCallback: jest.fn(), + })), + createObservableGauge: jest.fn().mockImplementation(() => ({ + addCallback: jest.fn(), + removeCallback: jest.fn(), + })), + }), + ); +} diff --git a/packages/backend-test-utils/src/alpha/services/index.ts b/packages/backend-test-utils/src/alpha/services/index.ts index ac92033ff8..527bb1822f 100644 --- a/packages/backend-test-utils/src/alpha/services/index.ts +++ b/packages/backend-test-utils/src/alpha/services/index.ts @@ -17,4 +17,5 @@ export { actionsRegistryServiceMock } from './ActionsRegistryServiceMock'; export { MockActionsRegistry } from './MockActionsRegistry'; export { actionsServiceMock } from './ActionsServiceMock'; +export { metricsServiceMock } from './MetricsServiceMock'; export { type ServiceMock } from './alphaCreateServiceMock'; diff --git a/packages/backend-test-utils/src/wiring/TestBackend.ts b/packages/backend-test-utils/src/wiring/TestBackend.ts index 43a63a49e5..11decb883d 100644 --- a/packages/backend-test-utils/src/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/wiring/TestBackend.ts @@ -43,6 +43,7 @@ import { HostDiscovery } from '@backstage/backend-defaults/discovery'; import { actionsRegistryServiceMock, actionsServiceMock, + metricsServiceMock, } from '../alpha/services'; /** @public */ @@ -92,6 +93,7 @@ export const defaultServiceFactories = [ // Alpha services actionsRegistryServiceMock.factory(), actionsServiceMock.factory(), + metricsServiceMock.factory(), ]; /** diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts index 790762a428..b9f3599b19 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.test.ts @@ -36,6 +36,7 @@ import { createRandomProcessingInterval } from '../processing/refresh'; import { timestampToDateTime } from './conversion'; import { generateStableHash } from './util'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; jest.setTimeout(60_000); @@ -59,6 +60,7 @@ describe('DefaultProcessingDatabase', () => { maxSeconds: 150, }), events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }), }; } diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts index a421ca19b2..264c11055d 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts @@ -47,6 +47,7 @@ import { DateTime } from 'luxon'; import { CATALOG_CONFLICTS_TOPIC } from '../constants'; import { CatalogConflictEventPayload } from '../catalog/types'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; // The number of items that are sent per batch to the database layer, when // doing .batchInsert calls to knex. This needs to be low enough to not cause @@ -60,6 +61,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { logger: LoggerService; refreshInterval: ProcessingIntervalFunction; events: EventsService; + metrics: MetricsService; }; constructor(options: { @@ -67,9 +69,10 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { logger: LoggerService; refreshInterval: ProcessingIntervalFunction; events: EventsService; + metrics: MetricsService; }) { this.options = options; - initDatabaseMetrics(options.database); + initDatabaseMetrics(options.database, options.metrics); } async updateProcessedEntity( diff --git a/plugins/catalog-backend/src/database/metrics.ts b/plugins/catalog-backend/src/database/metrics.ts index 809405d69f..f6a1464b3a 100644 --- a/plugins/catalog-backend/src/database/metrics.ts +++ b/plugins/catalog-backend/src/database/metrics.ts @@ -17,12 +17,12 @@ import { Knex } from 'knex'; import { createGaugeMetric } from '../util/metrics'; import { DbRelationsRow, DbLocationsRow, DbSearchRow } from './tables'; -import { metrics } from '@opentelemetry/api'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; -export function initDatabaseMetrics(knex: Knex) { +export function initDatabaseMetrics(knex: Knex, metrics: MetricsService) { const seenProm = new Set(); const seen = new Set(); - const meter = metrics.getMeter('default'); + return { entities_count_prom: createGaugeMetric({ name: 'catalog_entities_count', @@ -69,7 +69,7 @@ export function initDatabaseMetrics(knex: Knex) { this.set(Number(total[0].count)); }, }), - entities_count: meter + entities_count: metrics .createObservableGauge('catalog_entities_count', { description: 'Total amount of entities in the catalog', }) @@ -93,7 +93,7 @@ export function initDatabaseMetrics(knex: Knex) { } }); }), - registered_locations: meter + registered_locations: metrics .createObservableGauge('catalog_registered_locations_count', { description: 'Total amount of registered locations in the catalog', }) @@ -113,7 +113,7 @@ export function initDatabaseMetrics(knex: Knex) { gauge.observe(Number(total[0].count)); } }), - relations: meter + relations: metrics .createObservableGauge('catalog_relations_count', { description: 'Total amount of relations between entities', }) diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts index ae23830712..88829fc448 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.test.ts @@ -23,6 +23,7 @@ import { CatalogProcessingOrchestrator } from './types'; import { Stitcher } from '../stitching/types'; import { ConfigReader } from '@backstage/config'; import { mockServices } from '@backstage/backend-test-utils'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; describe('DefaultCatalogProcessingEngine', () => { const db = { @@ -72,6 +73,7 @@ describe('DefaultCatalogProcessingEngine', () => { createHash: () => hash, scheduler: mockServices.scheduler(), events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -141,6 +143,7 @@ describe('DefaultCatalogProcessingEngine', () => { scheduler: mockServices.scheduler(), createHash: () => hash, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -226,6 +229,7 @@ describe('DefaultCatalogProcessingEngine', () => { scheduler: mockServices.scheduler(), createHash: () => hash, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -305,6 +309,7 @@ describe('DefaultCatalogProcessingEngine', () => { scheduler: mockServices.scheduler(), createHash: () => hash, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -367,6 +372,7 @@ describe('DefaultCatalogProcessingEngine', () => { createHash: () => hash, pollingIntervalMs: 100, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -484,6 +490,7 @@ describe('DefaultCatalogProcessingEngine', () => { createHash: () => hash, pollingIntervalMs: 100, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -591,6 +598,7 @@ describe('DefaultCatalogProcessingEngine', () => { createHash: () => hash, pollingIntervalMs: 100, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -676,6 +684,7 @@ describe('DefaultCatalogProcessingEngine', () => { createHash: () => hash, pollingIntervalMs: 100, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); @@ -766,6 +775,7 @@ describe('DefaultCatalogProcessingEngine', () => { createHash: () => hash, pollingIntervalMs: 100, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); db.transaction.mockImplementation(cb => cb((() => {}) as any)); diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts index 3bcd4fcf44..9416fceea1 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts @@ -23,7 +23,7 @@ import { assertError, serializeError, stringifyError } from '@backstage/errors'; import { Hash } from 'node:crypto'; import stableStringify from 'fast-json-stable-stringify'; import { Knex } from 'knex'; -import { metrics, trace } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; import { ProcessingDatabase, RefreshStateItem } from '../database/types'; import { createCounterMetric, createSummaryMetric } from '../util/metrics'; import { CatalogProcessingOrchestrator, EntityProcessingResult } from './types'; @@ -39,6 +39,7 @@ import { deleteOrphanedEntities } from '../database/operations/util/deleteOrphan import { EventsService } from '@backstage/plugin-events-node'; import { CATALOG_ERRORS_TOPIC } from '../constants'; import { LoggerService, SchedulerService } from '@backstage/backend-plugin-api'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; const CACHE_TTL = 5; @@ -94,6 +95,7 @@ export class DefaultCatalogProcessingEngine { }) => Promise | void; tracker?: ProgressTracker; events: EventsService; + metrics: MetricsService; }) { this.config = options.config; this.scheduler = options.scheduler; @@ -106,7 +108,7 @@ export class DefaultCatalogProcessingEngine { this.pollingIntervalMs = options.pollingIntervalMs ?? 1_000; this.orphanCleanupIntervalMs = options.orphanCleanupIntervalMs ?? 30_000; this.onProcessingError = options.onProcessingError; - this.tracker = options.tracker ?? progressTracker(); + this.tracker = options.tracker ?? progressTracker(options.metrics); this.events = options.events; this.stopFunc = undefined; @@ -386,7 +388,7 @@ export class DefaultCatalogProcessingEngine { } // Helps wrap the timing and logging behaviors -function progressTracker() { +function progressTracker(metrics: MetricsService) { // prom-client metrics are deprecated in favour of OpenTelemetry metrics. const promProcessedEntities = createCounterMetric({ name: 'catalog_processed_entities_count', @@ -408,13 +410,12 @@ function progressTracker() { help: 'The amount of delay between being scheduled for processing, and the start of actually being processed, DEPRECATED, use OpenTelemetry metrics instead', }); - const meter = metrics.getMeter('default'); - const processedEntities = meter.createCounter( + const processedEntities = metrics.createCounter( 'catalog.processed.entities.count', { description: 'Amount of entities processed' }, ); - const processingDuration = meter.createHistogram( + const processingDuration = metrics.createHistogram( 'catalog.processing.duration', { description: 'Time spent executing the full processing flow', @@ -422,7 +423,7 @@ function progressTracker() { }, ); - const processorsDuration = meter.createHistogram( + const processorsDuration = metrics.createHistogram( 'catalog.processors.duration', { description: 'Time spent executing catalog processors', @@ -430,7 +431,7 @@ function progressTracker() { }, ); - const processingQueueDelay = meter.createHistogram( + const processingQueueDelay = metrics.createHistogram( 'catalog.processing.queue.delay', { description: diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index e3d0c26b0f..d0f2442f0c 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -117,6 +117,7 @@ import { import { filterAndSortProcessors, filterProviders } from './util'; import { GenericScmEventRefreshProvider } from '../providers/GenericScmEventRefreshProvider'; import { readScmEventHandlingConfig } from '../util/readScmEventHandlingConfig'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; export type CatalogEnvironment = { logger: LoggerService; @@ -131,6 +132,7 @@ export type CatalogEnvironment = { auditor: AuditorService; events: EventsService; catalogScmEvents: CatalogScmEventsService; + metrics: MetricsService; }; /** @@ -429,6 +431,7 @@ export class CatalogBuilder { httpAuth, events, catalogScmEvents, + metrics, } = this.env; const enableRelationsCompatibility = Boolean( @@ -448,6 +451,7 @@ export class CatalogBuilder { const stitcher = DefaultStitcher.fromConfig(config, { knex: dbClient, logger, + metrics, }); const processingDatabase = new DefaultProcessingDatabase({ @@ -455,6 +459,7 @@ export class CatalogBuilder { logger, events, refreshInterval: this.processingInterval, + metrics, }); const providerDatabase = new DefaultProviderDatabase({ database: dbClient, @@ -577,6 +582,7 @@ export class CatalogBuilder { this.onProcessingError?.(event); }, events, + metrics, }); const locationAnalyzer = diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index a032fd732e..915dd80dd9 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -43,10 +43,12 @@ import { } from '@backstage/plugin-catalog-node/alpha'; import { eventsServiceRef } from '@backstage/plugin-events-node'; import { Permission } from '@backstage/plugin-permission-common'; -import { metrics } from '@opentelemetry/api'; import { merge } from 'lodash'; import { CatalogBuilder } from './CatalogBuilder'; -import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { + actionsRegistryServiceRef, + metricsServiceRef, +} from '@backstage/backend-plugin-api/alpha'; import { createCatalogActions } from '../actions'; import type { EntityProviderEntry } from '../processing/connectEntityProviders'; @@ -220,6 +222,7 @@ export const catalogPlugin = createBackendPlugin({ catalog: catalogServiceRef, actionsRegistry: actionsRegistryServiceRef, catalogScmEvents: catalogScmEventsServiceRef, + metrics: metricsServiceRef, }, async init({ logger, @@ -238,6 +241,7 @@ export const catalogPlugin = createBackendPlugin({ auditor, events, catalogScmEvents, + metrics, }) { const builder = await CatalogBuilder.create({ config, @@ -252,6 +256,7 @@ export const catalogPlugin = createBackendPlugin({ auditor, events, catalogScmEvents, + metrics, }); if (onProcessingError) { @@ -303,9 +308,7 @@ export const catalogPlugin = createBackendPlugin({ actionsRegistry, }); - // Track SCM event message counts as a metric - const meter = metrics.getMeter('default'); - const scmEventsMessagesCounter = meter.createCounter<{ + const scmEventsMessagesCounter = metrics.createCounter<{ eventType: string; }>('catalog.events.scm.messages', { description: diff --git a/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts b/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts index fffe2467b5..00ce749b4a 100644 --- a/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts @@ -38,6 +38,7 @@ import { DefaultRefreshService } from './DefaultRefreshService'; import { ConfigReader } from '@backstage/config'; import { DefaultStitcher } from '../stitching/DefaultStitcher'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; jest.setTimeout(60_000); @@ -58,6 +59,7 @@ describe('DefaultRefreshService', () => { logger, refreshInterval: () => 100, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }), catalogDb: new DefaultCatalogDatabase({ database: knex, @@ -115,6 +117,7 @@ describe('DefaultRefreshService', () => { const stitcher = DefaultStitcher.fromConfig(new ConfigReader({}), { knex, logger: defaultLogger, + metrics: metricsServiceMock.mock(), }); const engine = new DefaultCatalogProcessingEngine({ config: new ConfigReader({}), @@ -164,6 +167,7 @@ describe('DefaultRefreshService', () => { createHash: () => createHash('sha1'), pollingIntervalMs: 50, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); return engine; diff --git a/plugins/catalog-backend/src/stitching/DefaultStitcher.test.ts b/plugins/catalog-backend/src/stitching/DefaultStitcher.test.ts index 251aaa9080..88acf68dd3 100644 --- a/plugins/catalog-backend/src/stitching/DefaultStitcher.test.ts +++ b/plugins/catalog-backend/src/stitching/DefaultStitcher.test.ts @@ -25,6 +25,7 @@ import { DbSearchRow, } from '../database/tables'; import { DefaultStitcher } from './DefaultStitcher'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; jest.setTimeout(60_000); @@ -42,6 +43,7 @@ describe('Stitcher', () => { knex: db, logger, strategy: { mode: 'immediate' }, + metrics: metricsServiceMock.mock(), }); let entities: DbFinalEntitiesRow[]; let entity: Entity; diff --git a/plugins/catalog-backend/src/stitching/DefaultStitcher.ts b/plugins/catalog-backend/src/stitching/DefaultStitcher.ts index 14b3eca0d4..e7848961a6 100644 --- a/plugins/catalog-backend/src/stitching/DefaultStitcher.ts +++ b/plugins/catalog-backend/src/stitching/DefaultStitcher.ts @@ -31,6 +31,7 @@ import { stitchingStrategyFromConfig, } from './types'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; type DeferredStitchItem = Awaited< ReturnType @@ -55,11 +56,13 @@ export class DefaultStitcher implements Stitcher { options: { knex: Knex; logger: LoggerService; + metrics: MetricsService; }, ): DefaultStitcher { return new DefaultStitcher({ knex: options.knex, logger: options.logger, + metrics: options.metrics, strategy: stitchingStrategyFromConfig(config), }); } @@ -67,12 +70,17 @@ export class DefaultStitcher implements Stitcher { constructor(options: { knex: Knex; logger: LoggerService; + metrics: MetricsService; strategy: StitchingStrategy; }) { this.knex = options.knex; this.logger = options.logger; this.strategy = options.strategy; - this.tracker = progressTracker(options.knex, options.logger); + this.tracker = progressTracker( + options.knex, + options.logger, + options.metrics, + ); } async stitch(options: { diff --git a/plugins/catalog-backend/src/stitching/progressTracker.ts b/plugins/catalog-backend/src/stitching/progressTracker.ts index 9e00a1a01b..f460a17a51 100644 --- a/plugins/catalog-backend/src/stitching/progressTracker.ts +++ b/plugins/catalog-backend/src/stitching/progressTracker.ts @@ -15,31 +15,33 @@ */ import { stringifyError } from '@backstage/errors'; -import { metrics } from '@opentelemetry/api'; import { Knex } from 'knex'; import { DateTime } from 'luxon'; import { DbRefreshStateRow } from '../database/tables'; import { createCounterMetric } from '../util/metrics'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { MetricsService } from '@backstage/backend-plugin-api/alpha'; // Helps wrap the timing and logging behaviors -export function progressTracker(knex: Knex, logger: LoggerService) { +export function progressTracker( + knex: Knex, + logger: LoggerService, + metrics: MetricsService, +) { // prom-client metrics are deprecated in favour of OpenTelemetry metrics. const promStitchedEntities = createCounterMetric({ name: 'catalog_stitched_entities_count', help: 'Amount of entities stitched. DEPRECATED, use OpenTelemetry metrics instead', }); - const meter = metrics.getMeter('default'); - - const stitchedEntities = meter.createCounter( + const stitchedEntities = metrics.createCounter( 'catalog.stitched.entities.count', { description: 'Amount of entities stitched', }, ); - const stitchingDuration = meter.createHistogram( + const stitchingDuration = metrics.createHistogram( 'catalog.stitching.duration', { description: 'Time spent executing the full stitching flow', @@ -47,7 +49,7 @@ export function progressTracker(knex: Knex, logger: LoggerService) { }, ); - const stitchingQueueCount = meter.createObservableGauge( + const stitchingQueueCount = metrics.createObservableGauge( 'catalog.stitching.queue.length', { description: 'Number of entities currently in the stitching queue' }, ); @@ -58,7 +60,7 @@ export function progressTracker(knex: Knex, logger: LoggerService) { result.observe(Number(total[0].count)); }); - const stitchingQueueDelay = meter.createHistogram( + const stitchingQueueDelay = metrics.createHistogram( 'catalog.stitching.queue.delay', { description: diff --git a/plugins/catalog-backend/src/tests/integration.test.ts b/plugins/catalog-backend/src/tests/integration.test.ts index a213d41efb..d03a23f896 100644 --- a/plugins/catalog-backend/src/tests/integration.test.ts +++ b/plugins/catalog-backend/src/tests/integration.test.ts @@ -56,6 +56,7 @@ import { mockServices, TestDatabases } from '@backstage/backend-test-utils'; import { LoggerService } from '@backstage/backend-plugin-api'; import { entitiesResponseToObjects } from '../service/response'; import { deleteOrphanedEntities } from '../database/operations/util/deleteOrphanedEntities'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; const voidLogger = mockServices.logger.mock(); @@ -248,6 +249,7 @@ class TestHarness { logger, events: mockServices.events.mock(), refreshInterval: () => 0.05, + metrics: metricsServiceMock.mock(), }); const integrations = ScmIntegrations.fromConfig(config); @@ -280,6 +282,7 @@ class TestHarness { const stitcher = DefaultStitcher.fromConfig(config, { knex: options.db, logger, + metrics: metricsServiceMock.mock(), }); const catalog = new DefaultEntitiesCatalog({ database: options.db, @@ -306,6 +309,7 @@ class TestHarness { }, tracker: proxyProgressTracker, events: mockServices.events.mock(), + metrics: metricsServiceMock.mock(), }); const refresh = new DefaultRefreshService({ database: catalogDatabase }); diff --git a/plugins/catalog-backend/src/tests/performance/getProcessableEntitiesPerformance.test.ts b/plugins/catalog-backend/src/tests/performance/getProcessableEntitiesPerformance.test.ts index 6efa3abbc7..53991e4416 100644 --- a/plugins/catalog-backend/src/tests/performance/getProcessableEntitiesPerformance.test.ts +++ b/plugins/catalog-backend/src/tests/performance/getProcessableEntitiesPerformance.test.ts @@ -19,6 +19,7 @@ import { Knex } from 'knex'; import { DefaultProcessingDatabase } from '../../database/DefaultProcessingDatabase'; import { applyDatabaseMigrations } from '../../database/migrations'; import { describePerformanceTest, performanceTraceEnabled } from './lib/env'; +import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; // #region Helpers @@ -81,6 +82,7 @@ describePerformanceTest('getProcessableEntities', () => { logger: mockServices.logger.mock(), events: mockServices.events.mock(), refreshInterval: () => 0, + metrics: metricsServiceMock.mock(), }); const start = Date.now();