feat(metrics): Implement MetricsService (#32977)

* feat: add MetricsService alpha release

Introduces MetricsService as a new @alpha core service wrapping
@opentelemetry/api. Includes migration of existing catalog metrics
to use the new service.

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: duplicate otel types, add plugin-scoped factory and tests

Signed-off-by: benjdlambert <ben@blam.sh>

* chore: update BEP-0012 metrics service design

Signed-off-by: Kurt King <kurtaking@gmail.com>

* chore: address PR feedback from freben and rugvip

Rename instrument types with MetricsService prefix for namespace
clarity, move config to backend.metrics.plugin.{pluginId}, add
config.d.ts schema, and improve factory test assertions.

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
Signed-off-by: Kurt King <kurtaking@gmail.com>
Co-authored-by: Kurt King <kurtaking@gmail.com>
This commit is contained in:
Ben Lambert
2026-02-24 16:57:02 +01:00
committed by GitHub
parent 11c4e69eb7
commit 1ee5b28e41
34 changed files with 1121 additions and 64 deletions
+5
View File
@@ -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.
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Adds a new metrics service mock to be leveraged in tests
+34 -35
View File
@@ -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
```
+30
View File
@@ -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.
*/
@@ -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,
@@ -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,
@@ -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();
});
});
});
@@ -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<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes> {
return this.meter.createCounter(name, opts);
}
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes> {
return this.meter.createUpDownCounter(name, opts);
}
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes> {
return this.meter.createHistogram(name, opts);
}
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes> {
return this.meter.createGauge(name, opts);
}
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes> {
return this.meter.createObservableCounter(name, opts);
}
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes> {
return this.meter.createObservableUpDownCounter(name, opts);
}
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes> {
return this.meter.createObservableGauge(name, opts);
}
}
@@ -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';
@@ -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();
});
});
@@ -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 });
},
});
@@ -16,4 +16,5 @@
export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
export { actionsServiceFactory } from './entrypoints/actions';
export { metricsServiceFactory } from './entrypoints/metrics';
export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata';
@@ -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<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
// @alpha
export interface MetricOptions {
advice?: MetricAdvice;
description?: string;
unit?: string;
}
// @alpha
export interface MetricsService {
createCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes>;
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes>;
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes>;
createObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableCounter<TAttributes>;
createObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableGauge<TAttributes>;
createObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
>(
name: string,
opts?: MetricOptions,
): MetricsServiceObservableUpDownCounter<TAttributes>;
createUpDownCounter<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes>;
}
// @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<TAttributes>): void;
// (undocumented)
removeCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
}
// @alpha
export type MetricsServiceObservableCallback<
TAttributes extends MetricAttributes = MetricAttributes,
> = (
observableResult: MetricsServiceObservableResult<TAttributes>,
) => void | Promise<void>;
// @alpha
export type MetricsServiceObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export type MetricsServiceObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @alpha
export interface MetricsServiceObservableResult<
TAttributes extends MetricAttributes = MetricAttributes,
> {
// (undocumented)
observe(value: number, attributes?: TAttributes): void;
}
// @alpha
export type MetricsServiceObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
// @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)
@@ -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<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
/**
* 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<TAttributes>,
) => void | Promise<void>;
/**
* An observable metric instrument that reports values via callbacks.
*
* @alpha
*/
export interface MetricsServiceObservable<
TAttributes extends MetricAttributes = MetricAttributes,
> {
addCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
removeCallback(callback: MetricsServiceObservableCallback<TAttributes>): void;
}
/**
* An observable counter metric that reports non-negative sums via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* An observable counter metric that reports sums that can go up or down
* via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableUpDownCounter<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* An observable gauge metric that reports instantaneous values via callbacks.
*
* @alpha
*/
export type MetricsServiceObservableGauge<
TAttributes extends MetricAttributes = MetricAttributes,
> = MetricsServiceObservable<TAttributes>;
/**
* 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<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceCounter<TAttributes>;
/**
* 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<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceUpDownCounter<TAttributes>;
/**
* Creates a new histogram metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The histogram metric.
*/
createHistogram<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceHistogram<TAttributes>;
/**
* Creates a new gauge metric.
*
* @param name - The name of the metric.
* @param opts - The options for the metric.
* @returns The gauge metric.
*/
createGauge<TAttributes extends MetricAttributes = MetricAttributes>(
name: string,
opts?: MetricOptions,
): MetricsServiceGauge<TAttributes>;
/**
* 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<TAttributes>;
/**
* 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<TAttributes>;
/**
* 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<TAttributes>;
}
@@ -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';
@@ -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',
});
@@ -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<ActionsService>;
}
// @alpha (undocumented)
export namespace metricsServiceMock {
const // (undocumented)
factory: () => ServiceFactory<MetricsService, 'plugin', 'singleton'>;
const // (undocumented)
mock: (
partialImpl?: Partial<MetricsService> | undefined,
) => ServiceMock<MetricsService>;
}
// @alpha
export class MockActionsRegistry
implements ActionsRegistryService, ActionsService
@@ -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<MetricsService>(
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(),
})),
}),
);
}
@@ -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';
@@ -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(),
];
/**
@@ -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(),
}),
};
}
@@ -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(
@@ -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<string>();
const seen = new Set<string>();
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',
})
@@ -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));
@@ -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> | 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:
@@ -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 =
@@ -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:
@@ -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;
@@ -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;
@@ -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<typeof getDeferredStitchableEntities>
@@ -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: {
@@ -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:
@@ -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 });
@@ -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();