Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions workspaces/scorecard/.changeset/fair-jeans-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-node': minor
---

Introduce collectors extension point to use by metric providers to collect data from different datasources.
6 changes: 6 additions & 0 deletions workspaces/scorecard/plugins/scorecard-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ This policy would allow users to read only the GitHub Open PRs metric, while res
The Scorecard plugin collects metrics from third-party data sources using metric providers. The Scorecard node plugin provides `scorecardMetricsExtensionPoint` extension point that is used to connect your backend plugin module that exports custom metrics via metric providers to the Scorecard backend plugin. For detailed information on creating metric providers, see [providers.md](./docs/providers.md).

### Collectors

Collectors are reusable data-fetching contracts used by metric providers. They are registered through `scorecardCollectorsExtensionPoint` and consumed via `collectWithContract`.

For details and examples, see [collectors.md](./docs/collectors.md).

### Metric Collection Scheduling

The Scorecard plugin automatically collects metrics on a scheduled basis. You can customize the schedule for any metric provider in your `app-config.yaml`. If no schedule is configured, metric providers use the following default schedule:
Expand Down
155 changes: 155 additions & 0 deletions workspaces/scorecard/plugins/scorecard-backend/docs/collectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Collectors

Collectors are reusable data-fetching components used by metric providers.

Use collectors when:

- multiple metric providers share datasource logic (including auth/API client setup)
- you are building composite providers that combine data from one or more datasources
- you want to keep providers focused on metric calculation logic

## Core APIs

Collector APIs are provided by `@red-hat-developer-hub/backstage-plugin-scorecard-node`:

- `Collector`
- `CollectorRegistry`
- `CollectorContract`
- `collectWithContract`
- `scorecardCollectorsExtensionPoint`

## Collector ID convention

Use `source:name` for collector IDs (for example `github:deployments`, `github:commit-pulls`).

This keeps collector IDs visually distinct from metric/provider IDs that use dot notation.

## Create a collector

Implement `Collector` with an input schema, output schema, and collect function:

```ts
import type { Entity } from '@backstage/catalog-model';
import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { z } from 'zod';

export class MyDeploymentsCollector
implements
Collector<
(typeof MyDeploymentsCollector)['inputSchema'],
(typeof MyDeploymentsCollector)['outputSchema']
>
{
static readonly inputSchema = z.object({
from: z.string().datetime(),
to: z.string().datetime(),
});

static readonly outputSchema = z.object({
deployments: z.array(
z.object({
id: z.number(),
sha: z.string(),
createdAt: z.string(),
}),
),
});

getCollectorId(): string {
return 'my-source:deployments';
}

getCollectorDescription(): string {
return 'Collect deployments in a time window';
}

getInputSchema() {
return MyDeploymentsCollector.inputSchema;
}

getOutputSchema() {
return MyDeploymentsCollector.outputSchema;
}

async collect(options: {
entity: Entity;
input: z.infer<(typeof MyDeploymentsCollector)['inputSchema']>;
}): Promise<z.infer<(typeof MyDeploymentsCollector)['outputSchema']>> {
return {
deployments: [],
};
}
}
```

## Register collectors in a backend module

Register collectors through `scorecardCollectorsExtensionPoint`:

```ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { scorecardCollectorsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { MyDeploymentsCollector } from './collectors/MyDeploymentsCollector';

export const scorecardModuleMySource = createBackendModule({
pluginId: 'scorecard',
moduleId: 'my-source',
register(reg) {
reg.registerInit({
deps: {
collectors: scorecardCollectorsExtensionPoint,
},
async init({ collectors }) {
collectors.addCollector(new MyDeploymentsCollector());
},
});
},
});
```

## Use collectors from a metric provider

Use `collectWithContract` to validate both sides of the contract (provider and collector expected input and output):

```ts
import type { Entity } from '@backstage/catalog-model';
import {
collectWithContract,
type CollectorRegistry,
type MetricProvider,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { z } from 'zod';

const inputSchema = z.object({
from: z.string().datetime(),
to: z.string().datetime(),
});

const outputSchema = z.object({
deployments: z.array(z.object({ id: z.number(), sha: z.string() })),
});

export class MyMetricProvider implements MetricProvider<'number'> {
constructor(private readonly collectorRegistry: CollectorRegistry) {}

// Other MetricProvider methods omitted

async calculateMetric(entity: Entity): Promise<number> {
const collected = await collectWithContract({
collectorRegistry: this.collectorRegistry,
collectorId: 'my-source:deployments',
contract: {
inputSchema,
outputSchema,
},
entity,
input: {
from: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
to: new Date().toISOString(),
},
});

return collected.deployments.length;
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

The Scorecard plugin collects metrics from third-party data sources using metric providers. The Scorecard node plugin provides `scorecardMetricsExtensionPoint` extension point that is used to connect your backend plugin module that exports custom metrics via metric providers to the Scorecard backend plugin. In this documentation, we will discuss how to create a simple metric provider backend module that will be used to collect and calculate metrics.

For provider data-fetching reuse across datasources, Scorecard provides collector contracts:

- `scorecardCollectorsExtensionPoint` to register collectors
- `collectWithContract(...)` helper to call a collector with dual validation:
- provider expected input/output schemas
- collector declared input/output schemas

For details and examples, see [collectors.md](./docs/collectors.md).

## Getting started

First step is to create a metric provider backend module using the following command:
Expand Down
16 changes: 16 additions & 0 deletions workspaces/scorecard/plugins/scorecard-backend/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import { createRouter } from './service/router';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import {
MetricProvider,
scorecardCollectorsExtensionPoint,
scorecardMetricsExtensionPoint,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { MetricProvidersRegistry } from './providers/MetricProvidersRegistry';
import { CollectorRegistry } from './providers/CollectorRegistry';
import { CatalogMetricService } from './service/CatalogMetricService';
import { ThresholdEvaluator } from './threshold/ThresholdEvaluator';
import { scorecardPermissions } from '@red-hat-developer-hub/backstage-plugin-scorecard-common';
Expand All @@ -47,6 +49,7 @@ export const scorecardPlugin = createBackendPlugin({
pluginId: 'scorecard',
register(env) {
const metricProvidersRegistry = new MetricProvidersRegistry();
const collectorRegistry = new CollectorRegistry();

env.registerExtensionPoint(scorecardMetricsExtensionPoint, {
addMetricProvider(...newMetricProviders: MetricProvider[]) {
Expand All @@ -55,6 +58,19 @@ export const scorecardPlugin = createBackendPlugin({
});
},
});
env.registerExtensionPoint(scorecardCollectorsExtensionPoint, {
addCollector(...collectors) {
collectors.forEach(collector => {
collectorRegistry.register(collector);
});
},
getCollector(collectorId: string) {
return collectorRegistry.getCollector(collectorId);
},
hasCollector(collectorId: string) {
return collectorRegistry.hasCollector(collectorId);
},
});

env.registerInit({
deps: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright Red Hat, Inc.
*
* 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 { ConflictError, NotFoundError } from '@backstage/errors';
import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { z } from 'zod';
import { CollectorRegistry } from './CollectorRegistry';

describe('CollectorRegistry', () => {
const collector: Collector = {
getCollectorId: () => 'github:deployments',
getCollectorDescription: () => 'Collect github deployments',
getInputSchema: () =>
z.object({
from: z.string().datetime(),
to: z.string().datetime(),
}),
getOutputSchema: () =>
z.object({
deployments: z.array(z.object({ sha: z.string() })),
}),
collect: jest.fn(),
};

it('registers and resolves collector by id', () => {
const registry = new CollectorRegistry();
registry.register(collector);

expect(registry.hasCollector(collector.getCollectorId())).toBe(true);
expect(registry.getCollector(collector.getCollectorId())).toBe(collector);
});

it('throws on duplicate collector id', () => {
const registry = new CollectorRegistry();
registry.register(collector);

expect(() => registry.register(collector)).toThrow(
new ConflictError(
"Collector with ID 'github:deployments' has already been registered",
),
);
});

it('throws on missing collector', () => {
const registry = new CollectorRegistry();
expect(() => registry.getCollector('missing.collector')).toThrow(
new NotFoundError(
"No collector registered for collector ID 'missing.collector'.",
),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright Red Hat, Inc.
*
* 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 { ConflictError, NotFoundError } from '@backstage/errors';
import type { Collector } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';

export class CollectorRegistry {
private readonly collectors = new Map<string, Collector>();

register(collector: Collector): void {
const collectorId = collector.getCollectorId();
if (this.collectors.has(collectorId)) {
throw new ConflictError(
`Collector with ID '${collectorId}' has already been registered`,
);
}
this.collectors.set(collectorId, collector);
}

getCollector(collectorId: string): Collector {
const collector = this.collectors.get(collectorId);
if (!collector) {
throw new NotFoundError(
`No collector registered for collector ID '${collectorId}'.`,
);
}
return collector;
}

hasCollector(collectorId: string): boolean {
return this.collectors.has(collectorId);
}
}
3 changes: 2 additions & 1 deletion workspaces/scorecard/plugins/scorecard-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"@backstage/catalog-model": "^1.7.7",
"@backstage/errors": "^1.2.7",
"@backstage/plugin-catalog-node": "^2.1.0",
"@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^"
"@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^",
"zod": "^3.22.4"
},
"devDependencies": {
"@backstage/backend-test-utils": "^1.11.1",
Expand Down
Loading
Loading