diff --git a/.changeset/orange-mugs-post-1.md b/.changeset/orange-mugs-post-1.md new file mode 100644 index 0000000000..bbf2b51082 --- /dev/null +++ b/.changeset/orange-mugs-post-1.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added opentelemetry metrics for SCM events: + +- `catalog.events.scm.messages` with attribute `eventType`: Counter for the number of SCM events actually received by the catalog backend. The `eventType` is currently either `location` or `repository`. diff --git a/.changeset/orange-mugs-post-2.md b/.changeset/orange-mugs-post-2.md new file mode 100644 index 0000000000..ed78bbc175 --- /dev/null +++ b/.changeset/orange-mugs-post-2.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +Added the ability for SCM events subscribers to mark the fact that they have taken actions based on events, which produces output metrics: + +- `catalog.events.scm.actions` with attribute `action`: Counter for the number of actions actually taken by catalog internals or other subscribers, based on SCM events. The `action` is currently either `create`, `delete`, `refresh`, or `move`. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 2c4b234fb2..f6f37b17cf 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -322,6 +322,7 @@ openapi OpenSearch OpenShift openssl +opentelemetry orgs overridable padding diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts index f12293fa1f..e1bd8ec386 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts @@ -35,6 +35,7 @@ describe('DefaultLocationStore', () => { const mockScmEvents = { subscribe: jest.fn(), publish: jest.fn(), + markEventActionTaken: jest.fn(), }; let subscriber: CatalogScmEventsServiceSubscriber | undefined; @@ -362,6 +363,11 @@ describe('DefaultLocationStore', () => { }, ], }); + + expect(mockScmEvents.markEventActionTaken).toHaveBeenCalledWith({ + count: 1, + action: 'delete', + }); }); }); @@ -483,6 +489,15 @@ describe('DefaultLocationStore', () => { ], removed: [], }); + + expect(mockScmEvents.markEventActionTaken).toHaveBeenCalledWith({ + count: 1, + action: 'delete', + }); + expect(mockScmEvents.markEventActionTaken).toHaveBeenCalledWith({ + count: 1, + action: 'create', + }); }); }); @@ -589,6 +604,11 @@ describe('DefaultLocationStore', () => { }, ], }); + + expect(mockScmEvents.markEventActionTaken).toHaveBeenCalledWith({ + count: 1, + action: 'delete', + }); }); }); @@ -709,6 +729,11 @@ describe('DefaultLocationStore', () => { ], removed: [], }); + + expect(mockScmEvents.markEventActionTaken).toHaveBeenCalledWith({ + count: 1, + action: 'move', + }); }); }); }); diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index 305bd611dc..bfd4270696 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -305,16 +305,40 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { } if (exactLocationsToDelete.size > 0) { - await this.#deleteLocationsByExactUrl(exactLocationsToDelete); + const count = await this.#deleteLocationsByExactUrl( + exactLocationsToDelete, + ); + this.scmEvents.markEventActionTaken({ + count, + action: 'delete', + }); } if (locationPrefixesToDelete.size > 0) { - await this.#deleteLocationsByUrlPrefix(locationPrefixesToDelete); + const count = await this.#deleteLocationsByUrlPrefix( + locationPrefixesToDelete, + ); + this.scmEvents.markEventActionTaken({ + count, + action: 'delete', + }); } if (exactLocationsToCreate.size > 0) { - await this.#createLocationsByExactUrl(exactLocationsToCreate); + const count = await this.#createLocationsByExactUrl( + exactLocationsToCreate, + ); + this.scmEvents.markEventActionTaken({ + count, + action: 'create', + }); } if (locationPrefixesToMove.size > 0) { - await this.#moveLocationsByUrlPrefix(locationPrefixesToMove); + const count = await this.#moveLocationsByUrlPrefix( + locationPrefixesToMove, + ); + this.scmEvents.markEventActionTaken({ + count, + action: 'move', + }); } } diff --git a/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts b/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts index fb54caab1b..e6c1c701d3 100644 --- a/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts +++ b/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts @@ -50,6 +50,7 @@ describe('GenericScmEventRefreshProvider', () => { return { unsubscribe: () => {} }; }), publish: jest.fn(), + markEventActionTaken: jest.fn(), }; const store = new GenericScmEventRefreshProvider(knex, scmEvents, { diff --git a/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.ts b/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.ts index 9d62ef1bdf..31cb6eb62b 100644 --- a/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.ts +++ b/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.ts @@ -150,6 +150,8 @@ export class GenericScmEventRefreshProvider implements EntityProvider { count += Number(result); } + + this.#scmEvents.markEventActionTaken({ count, action: 'refresh' }); } } diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 26b09b9eea..a032fd732e 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -43,6 +43,7 @@ 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'; @@ -301,6 +302,24 @@ export const catalogPlugin = createBackendPlugin({ catalog, actionsRegistry, }); + + // Track SCM event message counts as a metric + const meter = metrics.getMeter('default'); + const scmEventsMessagesCounter = meter.createCounter<{ + eventType: string; + }>('catalog.events.scm.messages', { + description: + 'Number of SCM event messages received by the catalog backend', + unit: 'short', + }); + catalogScmEvents.subscribe({ + onEvents: async e => { + for (const event of e) { + const eventType = event.type.split('.')[0]; + scmEventsMessagesCounter.add(1, { eventType }); + } + }, + }); }, }); }, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index aa3ed3a551..e13d33d58d 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -1429,6 +1429,7 @@ describe('POST /locations/by-query works end to end', () => { const mockScmEvents = { subscribe: jest.fn(), publish: jest.fn(), + markEventActionTaken: jest.fn(), }; const store = new DefaultLocationStore(knex, mockScmEvents, { diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index f8ce28451b..b06bd8ca08 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -68,6 +68,7 @@ "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", + "@opentelemetry/api": "^1.9.0", "lodash": "^4.17.21", "yaml": "^2.0.0" }, diff --git a/plugins/catalog-node/report-alpha.api.md b/plugins/catalog-node/report-alpha.api.md index aad7518921..0598834c67 100644 --- a/plugins/catalog-node/report-alpha.api.md +++ b/plugins/catalog-node/report-alpha.api.md @@ -125,6 +125,7 @@ export type CatalogScmEventContext = { // @alpha export interface CatalogScmEventsService { + markEventActionTaken(options: { count?: number; action: string }): void; publish(events: CatalogScmEvent[]): Promise; subscribe(subscriber: CatalogScmEventsServiceSubscriber): { unsubscribe: () => void; diff --git a/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.test.ts b/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.test.ts index 4adbb3b736..91a21894c8 100644 --- a/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.test.ts +++ b/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.test.ts @@ -15,11 +15,21 @@ */ import { createDeferred } from '@backstage/types'; +import { MetricsAPI } from '@opentelemetry/api'; import { DefaultCatalogScmEventsService } from './DefaultCatalogScmEventsService'; describe('DefaultCatalogScmEventsService', () => { + const counterAdd = jest.fn(); + const mockMetrics = { + getMeter: () => ({ + createCounter: () => ({ + add: counterAdd, + }), + }), + } as unknown as MetricsAPI; + it('should publish and subscribe to events', async () => { - const service = new DefaultCatalogScmEventsService(); + const service = new DefaultCatalogScmEventsService(mockMetrics); const subscriber1 = { onEvents: jest.fn(), @@ -53,7 +63,7 @@ describe('DefaultCatalogScmEventsService', () => { }); it('waits for all subscribers to acknowledge the events', async () => { - const service = new DefaultCatalogScmEventsService(); + const service = new DefaultCatalogScmEventsService(mockMetrics); const work1 = createDeferred(); const work2 = createDeferred(); @@ -102,4 +112,12 @@ describe('DefaultCatalogScmEventsService', () => { expect(completed).toBe(true); }); + + it('marks event actions taken', () => { + const service = new DefaultCatalogScmEventsService(mockMetrics); + + service.markEventActionTaken({ action: 'refresh' }); + + expect(counterAdd).toHaveBeenCalledWith(1, { action: 'refresh' }); + }); }); diff --git a/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.ts b/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.ts index 7610b7fd0a..b61878bbae 100644 --- a/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.ts +++ b/plugins/catalog-node/src/scmEvents/DefaultCatalogScmEventsService.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Counter, MetricsAPI } from '@opentelemetry/api'; import { CatalogScmEvent, CatalogScmEventsService, @@ -21,19 +22,36 @@ import { } from './types'; /** - * The default implementation of the {@link CatalogScmEventsService}/{@link catalogScmEventsServiceRef}. + * The default implementation of the + * {@link CatalogScmEventsService}/{@link catalogScmEventsServiceRef}. * * @internal * @remarks * * This implementation is in-memory, which requires the producers and consumer * (the catalog backend) to be deployed together. + * + * It's defined in here instead of in the catalog-backend plugin because this + * allows us to have a default factory whether you happen to be co-installed + * with the catalog-backend plugin or not. */ export class DefaultCatalogScmEventsService implements CatalogScmEventsService { readonly #subscribers: Set; + readonly #metrics: { + actions: Counter<{ action: string }>; + }; - constructor() { + constructor(metrics: MetricsAPI) { this.#subscribers = new Set(); + + const meter = metrics.getMeter('default'); + this.#metrics = { + actions: meter.createCounter('catalog.events.scm.actions', { + description: + 'Number of actions taken as a result of SCM event messages', + unit: 'short', + }), + }; } subscribe(subscriber: CatalogScmEventsServiceSubscriber): { @@ -58,4 +76,8 @@ export class DefaultCatalogScmEventsService implements CatalogScmEventsService { }), ); } + + markEventActionTaken(options: { count?: number; action: string }): void { + this.#metrics.actions.add(options.count ?? 1, { action: options.action }); + } } diff --git a/plugins/catalog-node/src/scmEvents/catalogScmEventsServiceRef.ts b/plugins/catalog-node/src/scmEvents/catalogScmEventsServiceRef.ts index 1f5bc7165f..f940581ca0 100644 --- a/plugins/catalog-node/src/scmEvents/catalogScmEventsServiceRef.ts +++ b/plugins/catalog-node/src/scmEvents/catalogScmEventsServiceRef.ts @@ -18,6 +18,7 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; +import { metrics } from '@opentelemetry/api'; import { CatalogScmEventsService } from './types'; import { DefaultCatalogScmEventsService } from './DefaultCatalogScmEventsService'; @@ -39,7 +40,7 @@ export const catalogScmEventsServiceRef = service, deps: {}, createRootContext() { - return new DefaultCatalogScmEventsService(); + return new DefaultCatalogScmEventsService(metrics); }, factory(_, ctx) { return ctx; diff --git a/plugins/catalog-node/src/scmEvents/types.ts b/plugins/catalog-node/src/scmEvents/types.ts index 87cf31fc01..72161cacde 100644 --- a/plugins/catalog-node/src/scmEvents/types.ts +++ b/plugins/catalog-node/src/scmEvents/types.ts @@ -55,6 +55,26 @@ export interface CatalogScmEventsService { * guarantees. */ publish(events: CatalogScmEvent[]): Promise; + + /** + * As a consumer of SCM events, mark that you have taken an action as a result + * of an SCM event. + * + * This is typically used to record metrics or other observability signals + * about how SCM events are handled, for example counting how many refresh, + * delete, create, or move operations are triggered by incoming events. + */ + markEventActionTaken(options: { + /** + * The number of actions taken of the given type. Defaults to 1. + */ + count?: number; + /** + * The type of action taken - typically "refresh", "delete", + * "create", or "move". + */ + action: string; + }): void; } /** diff --git a/yarn.lock b/yarn.lock index 64f87ae3eb..2a41ca71e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5460,6 +5460,7 @@ __metadata: "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" + "@opentelemetry/api": "npm:^1.9.0" lodash: "npm:^4.17.21" msw: "npm:^1.0.0" yaml: "npm:^2.0.0"