Skip to content
41 changes: 41 additions & 0 deletions workspaces/scorecard/.changeset/seven-guests-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor
'@red-hat-developer-hub/backstage-plugin-scorecard': minor
---

Implemented endpoint to aggregate metrics for scorecard metrics

**BREAKING** Update attribute `value` in the `MetricResult` type and update validation to support `null` instead `undefined` for the updated attribute

```diff
export type MetricResult = {
id: string;
status: 'success' | 'error';
metadata: {
title: string;
description: string;
type: MetricType;
history?: boolean;
};
result: {
- value?: MetricValue;
+ value: MetricValue | null;
timestamp: string;
thresholdResult: ThresholdResult;
};
error?: string;
};
```

**BREAKING** Update attribute `evaluation` in the `ThresholdResult` type and update validation to support `null` instead `undefined` for the updated attribute

```diff
export type ThresholdResult = {
status: 'success' | 'error';
- definition: ThresholdConfig | undefined;
+ definition: ThresholdConfig | null;
evaluation: string | undefined; // threshold key the expression evaluated to
error?: string;
};
```
2 changes: 1 addition & 1 deletion workspaces/scorecard/examples/all-scorecards.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ metadata:
jira/project-key: RSPT
spec:
type: service
owner: janus-authors
owner: user:development/guest
lifecycle: production
6 changes: 3 additions & 3 deletions workspaces/scorecard/examples/entities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ kind: System
metadata:
name: examples
spec:
owner: guests
owner: group:development/guests
---
# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component
apiVersion: backstage.io/v1alpha1
Expand All @@ -15,7 +15,7 @@ metadata:
spec:
type: website
lifecycle: experimental
owner: guests
owner: group:development/guests
system: examples
providesApis: [example-grpc-api]
---
Expand All @@ -27,7 +27,7 @@ metadata:
spec:
type: grpc
lifecycle: experimental
owner: guests
owner: group:development/guests
system: examples
definition: |
syntax = "proto3";
Expand Down
2 changes: 1 addition & 1 deletion workspaces/scorecard/examples/github-scorecard-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ metadata:
backstage.io/source-location: url:https://github.com/redhat-developer/rhdh-plugins
spec:
type: service
owner: janus-authors
owner: group:development/guests
lifecycle: production
2 changes: 1 addition & 1 deletion workspaces/scorecard/examples/jira-scorecard-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ metadata:
jira/project-key: RSPT
spec:
type: service
owner: janus-authors
owner: group:development/guests
lifecycle: production
2 changes: 1 addition & 1 deletion workspaces/scorecard/examples/no-scorecards.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ metadata:
name: no-scorecards-service
spec:
type: service
owner: janus-authors
owner: group:development/guests
lifecycle: production
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ metadata:
name: ${{ values.name | dump }}
spec:
type: service
owner: user:guest
owner: user:development/guest
lifecycle: experimental
14 changes: 14 additions & 0 deletions workspaces/scorecard/plugins/scorecard-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ Thresholds are evaluated in order, and the first matching rule determines the ca

For comprehensive threshold configuration guide, examples, and best practices, see [thresholds.md](./docs/thresholds.md).

## Entity Aggregation

The Scorecard plugin provides aggregation endpoints that return metrics for all entities owned by the authenticated user. This includes:

- Entities directly owned by the user
- Entities owned by groups the user is a direct member of (Only direct parent groups are considered)

### Available Endpoints

- **`GET /metrics/catalog/aggregates`**: Returns aggregated metrics for all available metrics (optionally filtered by `metricIds` query parameter)
- **`GET /metrics/:metricId/catalog/aggregation`**: Returns aggregated metrics for a specific metric, with explicit access validation (returns `403` if the user doesn't have access to the metric)

For comprehensive documentation on how entity aggregation works, API details, examples, and best practices, see [aggregation.md](./docs/aggregation.md).

## Configuration cleanup Job

The plugin has a predefined job that runs every day to check and clean old metrics. By default, metrics are saved for **365 days**, however, this period can be changed in the `app-config.yaml` file. Here is an example of how to do that:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,48 @@
*/

import { DatabaseMetricValues } from '../src/database/DatabaseMetricValues';
import { DbMetricValue } from '../src/database/types';
import { DbMetricValue, DbAggregatedMetric } from '../src/database/types';

type BuildMockDatabaseMetricValuesParams = {
metricValues?: DbMetricValue[];
latestEntityMetric?: DbMetricValue[];
countOfExpiredMetrics?: number;
aggregatedMetrics?: DbAggregatedMetric[];
};

export const mockDatabaseMetricValues = {
createMetricValues: jest.fn(),
readLatestEntityMetricValues: jest.fn(),
cleanupExpiredMetrics: jest.fn(),
readAggregatedMetricsByEntityRefs: jest.fn(),
} as unknown as jest.Mocked<DatabaseMetricValues>;

export const buildMockDatabaseMetricValues = ({
metricValues,
latestEntityMetric,
countOfExpiredMetrics,
aggregatedMetrics,
}: BuildMockDatabaseMetricValuesParams) => {
const createMetricValues = metricValues
? jest.fn().mockResolvedValue(metricValues)
: mockDatabaseMetricValues.createMetricValues;

const readLatestEntityMetricValues = latestEntityMetric
? jest.fn().mockResolvedValue(latestEntityMetric)
: mockDatabaseMetricValues.readLatestEntityMetricValues;

const cleanupExpiredMetrics = countOfExpiredMetrics
? jest.fn().mockResolvedValue(countOfExpiredMetrics)
: mockDatabaseMetricValues.cleanupExpiredMetrics;

const readAggregatedMetricsByEntityRefs = aggregatedMetrics
? jest.fn().mockResolvedValue(aggregatedMetrics)
: mockDatabaseMetricValues.readAggregatedMetricsByEntityRefs;

return {
createMetricValues,
readLatestEntityMetricValues,
cleanupExpiredMetrics,
readAggregatedMetricsByEntityRefs,
} as unknown as jest.Mocked<DatabaseMetricValues>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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 type { Entity, EntityRelation } from '@backstage/catalog-model';

export class MockEntityBuilder {
private kind: string = 'Component';
private metadata: Entity['metadata'] = {
name: 'default-component',
namespace: 'default',
};
private spec: Entity['spec'] | undefined = undefined;
private relations: EntityRelation[] | undefined = undefined;

withKind(kind: string): this {
this.kind = kind;
return this;
}

withMetadata(metadata: Entity['metadata']): this {
this.metadata = metadata;
return this;
}

withSpec(spec: Entity['spec']): this {
this.spec = spec;
return this;
}

withRelations(relations: EntityRelation[]): this {
this.relations = relations;
return this;
}

build(): Entity {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: this.kind,
metadata: this.metadata,
spec: this.spec,
relations: this.relations,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ export const buildMockMetricProvidersRegistry = ({
? jest.fn().mockReturnValue(provider)
: jest.fn();
const listMetrics = metricsList
? jest.fn().mockReturnValue(metricsList)
? jest.fn().mockImplementation((metricIds?: string[]) => {
if (metricIds && metricIds.length !== 0) {
return metricsList.filter(metric => metricIds.includes(metric.id));
}
return metricsList;
})
: jest.fn();

return {
Expand Down
134 changes: 134 additions & 0 deletions workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Entity Aggregation

The Scorecard plugin provides aggregation endpoints that return metrics aggregated across all entities owned by the authenticated user. This feature allows users to get a consolidated view of metrics across their entire portfolio of owned entities.

## Overview

The aggregation endpoints (`/metrics/catalog/aggregates` and `/metrics/:metricId/catalog/aggregation`) aggregate metrics from multiple entities based on entity ownership. They collect metrics from:

- Entities directly owned by the user
- Entities owned by groups the user is a direct member of

The aggregation counts how many entities fall into each threshold category (`success`, `warning`, `error`) for each metric, providing a high-level overview of the health status across all owned entities.

### Important Limitation: Direct Parent Groups Only

**Only direct parent groups are considered.** The aggregation does not traverse nested group hierarchies.

**Example:**

Consider the following group structure:

- User `alice` is a member of `group:default/developers`
- `group:default/developers` is a member of `group:default/engineering`

In this case:

- ✅ Entities owned by `alice` directly are included
- ✅ Entities owned by `group:default/developers` are included
- ❌ Entities owned by `group:default/engineering` are **NOT** included

## API Endpoints

### `GET /metrics/catalog/aggregates`

Returns aggregated metrics for all entities owned by the authenticated user.

#### Query Parameters

| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | -------------------------------------------------------------------------------------------- |
| `metricIds` | string | No | Comma-separated list of metric IDs to filter. If not provided, returns all available metrics |

#### Authentication

Requires user authentication. The endpoint uses the authenticated user's entity reference to determine which entities to aggregate.

#### Permissions

Requires `scorecard.metric.read` permission. Additionally, the user must have `catalog.entity.read` permission for each entity that will be included in the aggregation.

#### Example Request

```bash
# Get all aggregated metrics
curl -X GET "{{url}}/api/scorecard/metrics/catalog/aggregates" \
-H "Authorization: Bearer <token>"

# Get specific metrics
curl -X GET "{{url}}/api/scorecard/metrics/catalog/aggregates?metricIds=github.open_prs,jira.open_issues" \
-H "Authorization: Bearer <token>"
```

### `GET /metrics/:metricId/catalog/aggregation`

Returns aggregated metrics for a specific metric across all entities owned by the authenticated user. This endpoint is useful when you need to check access to a specific metric and get its aggregation without requiring the `metricIds` query parameter.

#### Path Parameters

| Parameter | Type | Required | Description |
| ---------- | ------ | -------- | --------------------------------- |
| `metricId` | string | Yes | The ID of the metric to aggregate |

#### Authentication

Requires user authentication. The endpoint uses the authenticated user's entity reference to determine which entities to aggregate.

#### Permissions

Requires `scorecard.metric.read` permission. Additionally:

- The user must have access to the specific metric (returns `403 Forbidden` if access is denied)
- The user must have `catalog.entity.read` permission for each entity that will be included in the aggregation

#### Example Request

```bash
# Get aggregated metrics for a specific metric
curl -X GET "{{url}}/api/scorecard/metrics/github.open_prs/catalog/aggregation" \
-H "Authorization: Bearer <token>"
```

#### Differences from `/metrics/catalog/aggregates`

- **Metric Access Validation**: This endpoint explicitly validates that the user has access to the specified metric and returns `403 Forbidden` if access is denied
- **Single Metric Only**: Returns aggregation for only the specified metric (no need for `metricIds` query parameter)
- **Empty Results Handling**: Returns an empty array `[]` when the user owns no entities, avoiding errors when filtering by a single metric

## Error Handling

### Missing User Entity Reference

If the authenticated user doesn't have an entity reference in the catalog:

- **Status Code**: `403 Forbidden`
- **Error**: `NotAllowedError: User entity reference not found`

### Permission Denied

If the user doesn't have permission to read a specific entity:

- **Status Code**: `403 Forbidden`
- **Error**: Permission denied for the specific entity

### Metric Access Denied (for `/metrics/:metricId/catalog/aggregation`)

If the user doesn't have access to the specified metric:

- **Status Code**: `403 Forbidden`
- **Error**: `NotAllowedError: Access to metric "<metricId>" denied`

### Invalid Query Parameters

If invalid query parameters are provided:

- **Status Code**: `400 Bad Request`
- **Error**: Validation error details

## Best Practices

1. **Use Metric Filtering**: When you only need specific metrics, use the `metricIds` parameter to reduce response size and improve performance

2. **Handle Empty Results**: Always check for empty arrays when the user owns no entities

3. **Group Structure**: Be aware of the direct parent group limitation when designing your group hierarchy. If you need nested group aggregation, consider restructuring your groups or implementing custom logic
Loading
Loading