diff --git a/workspaces/scorecard/.changeset/aggregation-time-series.md b/workspaces/scorecard/.changeset/aggregation-time-series.md new file mode 100644 index 00000000000..52dcc3f67dc --- /dev/null +++ b/workspaces/scorecard/.changeset/aggregation-time-series.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +--- + +Add `GET /aggregations/:aggregationId/time-series` for daily scalar portfolio aggregation (`sum`, `average`, `max`, `min`, `count`). Returns aggregated metric values per UTC days. Days with no data are omitted. Aggregation type `statusGrouped` and `weightedStatusScore` return `400`. Sparkline metrics without a KPI block default to aggregation type `average`. + +Adds `metadata.visualization` type to `GET /aggregations/:aggregationId/metadata` response. diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index c6d42f95984..b249794a3fb 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -325,6 +325,48 @@ scorecard: description: This KPI provides a mean open issues count per entity. type: average metricId: jira.openIssues + avgDeploymentFrequency: + title: Average Deployment Frequency + description: This KPI provides average weekly production deploys over a 30-day window per entity. + type: average + metricId: dora.deploymentFrequency + options: + thresholds: + rules: + - key: elite + expression: '>=7' + color: success.main + icon: scorecardSuccessStatusIcon + - key: medium + expression: '1-7' + color: warning.main + icon: scorecardWarningStatusIcon + - key: error + expression: '<1' + color: error.main + icon: scorecardErrorStatusIcon + avgEliteDeploymentFrequency: + title: Average Elite Deployment Frequency + description: This KPI provides average elite weekly production deploys over a 30-day window per entity. + type: average + metricId: dora.deploymentFrequency + options: + thresholds: + rules: + - key: elite + expression: '>=7' + color: success.main + icon: scorecardSuccessStatusIcon + - key: medium + expression: '1-7' + color: warning.main + icon: scorecardWarningStatusIcon + - key: error + expression: '<1' + color: error.main + icon: scorecardErrorStatusIcon + filter: + status: elite entitiesWithOpenPrs: title: Entities with Open PRs description: This KPI provides a count of entities with a stored open-prs value. diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index e9c1f73d871..10d55ae4bc9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -203,6 +203,11 @@ scorecard: description: Mean open issues count per entity type: average metricId: jira.openIssues + avgDeploymentFrequency: + title: Average deployment frequency + description: Mean weekly production deploys across catalog entities you own. + type: average + metricId: dora.deploymentFrequency entitiesWithOpenPrs: title: Entities with Open PRs description: Count of entities with a stored open-prs value @@ -230,7 +235,7 @@ scorecard: | `options` | **Optional:** extra configuration attributes required to further configure the aggregated card for a specific type | - **Path**: `scorecard.aggregationKPIs.`. -- If **`aggregationKPIs` is omitted** or a given id is not listed, **`GET /aggregations/:aggregationId`** still works when **`aggregationId` equals the metric id** (e.g. `github.openPRs`): the backend uses that metric with the default `statusGrouped` aggregation and metric-defined title/description. +- If **`aggregationKPIs` is omitted** or a given id is not listed, aggregation KPIs still work, See [Default aggregation](./docs/aggregation.md#default-aggregation). - **Startup validation**: the backend validates every **`scorecard.aggregationKPIs`** entry when the plugin loads. Invalid configuration (including **`weightedStatusScore`** KPIs without **`options.statusScores`**, non-count scalar types on boolean metrics, invalid **`filter.status`** keys on scalar types, bad threshold expressions, or unregistered **`metricId`**) causes the backend to **fail to start** with a clear error. At runtime, some edge cases may still be logged (for example skipping a KPI with unusable weights); prefer correcting app-config. See [aggregation.md](./docs/aggregation.md#configuration-validation). **Homepage cards** are configured in the app (for example Dynamic Home Page mount points). They should pass **`aggregationId`** matching a key in `aggregationKPIs` or the metric id for the default case. See the [Scorecard frontend plugin README](../scorecard/README.md#homepage-scorecard-cards). @@ -342,7 +347,7 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service/ "description": "The number of open pull requests.", "type": "number", "history": true, - "defaultVisualization": "value" + "defaultVisualization": "donut" }, "points": [ { "value": 8, "timestamp": "2026-04-27T23:10:00.000Z" }, @@ -408,10 +413,7 @@ Returns aggregated metrics for the authenticated user across all catalog entitie Response **`result`** shape depends on **`metadata.aggregationType`**: status counts for **`statusGrouped`**, weighted score fields for **`weightedStatusScore`**, or scalar fields for **`sum`** / **`average`** / **`max`** / **`min`** / **`count`** — see [Scalar result fields](./docs/aggregation.md#scalar-result-fields). Scalar KPIs may also return **`metadata.filter`** when **`filter.status`** is configured. -The **`aggregationId`** is either: - -- A key under **`scorecard.aggregationKPIs`** in app-config (KPI-specific title, description, type, and `metricId`), or -- The **metric id** itself when no KPI entry exists (default **statusGrouped** behavior). +The **`aggregationId`** is a key under **`scorecard.aggregationKPIs`**, or a metric id when no KPI is configured. See [Default aggregation](#default-aggregation). #### Path Parameters @@ -434,15 +436,102 @@ curl -X GET "{{url}}/api/scorecard/aggregations/github.openPRs" \ -H "Authorization: Bearer " ``` +### `GET /aggregations/:aggregationId/time-series` + +Returns a **daily** history of a **scalar** KPI (`sum`, `average`, `max`, `min`, or `count`) across entities you own. Each response point is one UTC day: Scorecard takes **latest stored row** for each owned entity that day (including calculation failures), then rolls successful values up with the KPI’s aggregation type. Optional **`filter.status`** applies only to successes. UTC days with no rows are omitted; a day with only failures is included with **`value: null`**, **`status: error`** and **`errors`** list. + +Only [scalar](./docs/aggregation.md#scalar-types) aggregation types are supported. **`statusGrouped`** and **`weightedStatusScore`** return **`400 Bad Request`**. See [aggregation.md](./docs/aggregation.md#get-aggregationsaggregationidtime-series) for details. + +#### Path Parameters + +| Parameter | Type | Required | Description | +| --------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `aggregationId` | string | Yes | Same as `GET /aggregations/:aggregationId`. Must resolve to a scalar type (`sum`, `average`, `max`, `min`, `count`). | + +#### Query Parameters + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | -------------------------------------------------------------------- | +| `from` | string | Yes | Inclusive range start (ISO-8601) | +| `to` | string | Yes | Inclusive range end (ISO-8601); must be `>= from`; max span 365 days | + +#### Authentication / permissions + +Requires user authentication, `scorecard.metric.read` permission, and `catalog.entity.read` permission for each aggregated entity. + +#### Example Request + +```bash +curl -X GET "{{url}}/api/scorecard/aggregations/avgDeploymentFrequency/time-series?from=2026-08-24T00:00:00.000Z&to=2026-08-24T23:59:59.999Z" \ + -H "Authorization: Bearer " +``` + +### Example Response + +```json +{ + "id": "avgDeploymentFrequency", + "metricId": "dora.deploymentFrequency", + "metadata": { + "title": "Average Deployment Frequency", + "description": "This KPI provides average weekly production deploys over a 30-day window per entity.", + "type": "number", + "unit": "/week", + "history": true, + "visualization": "sparkline", + "aggregationType": "average" + }, + "points": [ + { + "value": 6.8, + "successCount": 4, + "errorCount": 3, + "total": 7, + "status": "success", + "timestamp": "2026-08-24T00:00:00.000Z", + "errors": [ + { "message": "GitHub API error", "count": 2 }, + { "message": "timeout", "count": 1 } + ] + } + ], + "thresholds": { + "rules": [ + { + "key": "elite", + "expression": ">=7", + "color": "success.main", + "icon": "scorecardSuccessStatusIcon" + }, + { + "key": "medium", + "expression": "1-7", + "color": "warning.main", + "icon": "scorecardWarningStatusIcon" + }, + { + "key": "error", + "expression": "<1", + "color": "error.main", + "icon": "scorecardErrorStatusIcon" + } + ] + }, + "aggregationChartDisplayColor": "warning.main" +} +``` + ### `GET /aggregations/:aggregationId/metadata` -Returns **title**, **description**, **type**, **unit**, **history**, and **aggregationType** for the aggregation without computing full aggregate counts. Includes **`filter`** when the KPI is a scalar type with **`filter.status`** configured. Uses the same resolution rules as `GET /aggregations/:aggregationId` (KPI config vs metric id fallback). +Returns **title**, **description**, **type**, **unit**, **history**, **visualization**, **aggregationType** for the aggregation without computing full aggregate counts. Includes **`filter`** when the KPI is a scalar type with **`filter.status`** configured. Uses the same resolution rules as `GET /aggregations/:aggregationId` (KPI config vs metric id fallback). ```bash curl -X GET "{{url}}/api/scorecard/aggregations/openIssuesKpi/metadata" \ -H "Authorization: Bearer " ``` +For endpoint details, see [aggregation.md](./docs/aggregation.md#get-aggregationsaggregationidmetadata). + ### `GET /metrics/:metricId/catalog/aggregations` (deprecated; removal planned) This endpoint **remains available** for backward compatibility and behaves like the default case of **`GET /aggregations/:metricId`** (status-grouped aggregation for that metric). **It will be removed in a future major release** of the plugin - migrate to **`GET /aggregations/:aggregationId`**. diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts index ccab20a37bf..d7596410582 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockDatabaseMetricValues.ts @@ -37,6 +37,7 @@ export const mockDatabaseMetricValues = { cleanupExpiredMetrics: jest.fn(), readAggregatedMetricByEntityRefs: jest.fn(), readScalarAggregatedMetricByEntityRefs: jest.fn(), + readScalarAggregatedMetricTimeSeriesByEntityRefs: jest.fn(), readEntityMetricsWithFilters: jest.fn(), } as unknown as jest.Mocked; @@ -80,6 +81,8 @@ export const buildMockDatabaseMetricValues = ({ cleanupExpiredMetrics, readAggregatedMetricByEntityRefs, readScalarAggregatedMetricByEntityRefs, + readScalarAggregatedMetricTimeSeriesByEntityRefs: + mockDatabaseMetricValues.readScalarAggregatedMetricTimeSeriesByEntityRefs, readEntityMetricsWithFilters, } as unknown as jest.Mocked; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md index 7c632618d6a..bb370356406 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/aggregation.md @@ -219,6 +219,37 @@ Scalar KPIs may include an optional top-level **`filter.status`** to restrict ag **Startup validation:** When **`filter.status`** is set, the backend validates at plugin load that the value is a **threshold rule key** for the KPI’s **`metricId`** (provider defaults plus app-config at **`scorecard.metricProviders...metrics..thresholds`** or provider-level **`scorecard.metricProviders...thresholds`** — for example `scorecard.metricProviders.jira.openIssues.metrics.openIssues.thresholds` when **`metricId`** is `jira.openIssues`). Keys are **case-sensitive** (`error` ≠ `Error`) and must be 1–64 characters. Invalid keys cause startup to fail with an error listing valid keys. Per-entity annotation threshold overrides are **not** considered (they apply at metric sync time only). See [thresholds.md — Scalar status filter](./thresholds.md#5-aggregation-kpi-result-thresholds-scalar-types). +## Default aggregation + +You do not need a KPI block for every metric. If the aggregation id is **not** a key under **`scorecard.aggregationKPIs`**, Scorecard treats it as a **metric id** (for example `github.openPRs`). Title and description for aggregation come from the metric itself. + +The aggregation type is then: + +- **`average`** when the metric’s **`defaultVisualization`** is **`sparkline`** +- **`statusGrouped`** otherwise + +The Scorecard **backend plugin logger** logs an **info** the first time an aggregation id is resolved with no matching KPI. + +```text +No "scorecard.aggregationKPIs.dora.deploymentFrequency" block in app-config; using default type "average" with metricId="dora.deploymentFrequency" (same as aggregation id). Add a KPI entry if you meant a custom title, description, or type. +``` + +Add a **`scorecard.aggregationKPIs`** entry when you need a custom title, a different type (for example **`sum`** or **`weightedStatusScore`**), **`filter`**, or **`options`**: + +```yaml +scorecard: + aggregationKPIs: + avgDeploymentFrequency: + title: Average Deployment Frequency + description: This KPI provides average weekly production deploys over a 30-day window per entity. + type: average + metricId: dora.deploymentFrequency +``` + +This default applies to **`GET /aggregations/:aggregationId`**, **`GET /aggregations/:aggregationId/time-series`**, and **`GET /aggregations/:aggregationId/metadata`**. Time-series only accepts scalar types, so a default **`statusGrouped`** metric id returns **`400`**; a sparkline metric’s default **`average`** works without extra config. + +**Homepage cards** are configured in the app (for example Dynamic Home Page mount points). They should pass **`aggregationId`** matching a key in `aggregationKPIs` or the metric id for the default case. See the [Scorecard frontend plugin README](../../scorecard/README.md#homepage-scorecard-cards). + ## Configuration validation - **`scorecard.aggregationKPIs`** is validated when the backend plugin starts. Invalid entries cause startup to fail with an error so misconfiguration is caught early. Fix app-config and redeploy. @@ -229,17 +260,20 @@ Scalar KPIs may include an optional top-level **`filter.status`** to restrict ag ### `GET /aggregations/:aggregationId` +Returns a **KPI snapshot**: the current aggregated value of a scorecard KPI across entities you own. Use this endpoint for all new integrations. - **`aggregationId`** may be a key under **`scorecard.aggregationKPIs`** in app-config (see the [backend README](../README.md#aggregation-kpis-homepage-and-get-aggregations)), which supplies **title**, **description**, **type**, **metricId**, and type-specific **`options`** (for example **`options.statusScores`** for **`weightedStatusScore`**, or optional **`options.thresholds`** for scalar types and **`weightedStatusScore`**). -- If there is **no** `scorecard.aggregationKPIs.` block, the backend still responds successfully: it treats **`aggregationId` as the `metricId`** and uses the default **statusGrouped** strategy (same as calling **`/aggregations/`** with a metric id). A **warning** is logged on the server so missing KPI config is visible in operator logs. To get a custom **title**, **`weightedStatusScore`** or **scalar** type, or other KPI options, you must add that block; a typo in the id falls through to this default and can look like “wrong” aggregation behavior in the UI, so check logs and app-config. +- If there is **no** `scorecard.aggregationKPIs.` block, the backend still responds successfully: it treats **`aggregationId` as the `metricId`**. The default type is **`average`** when the metric’s **`defaultVisualization`** is **`sparkline`**, otherwise **`statusGrouped`**. An **info** is logged on the server so missing KPI config is visible in operator logs. To get a custom **title**, **`weightedStatusScore`** or **scalar** type, or other KPI options, you must add that block; a typo in the id falls through to this default and can look like “wrong” aggregation behavior in the UI, so check logs and app-config. -The response shape includes **`id`**, **`status`**, **`metadata`** (title, description, type, unit, aggregation type, and **`filter`** when configured), and **`result`**. The shape of **`result`** depends on the aggregation type: +The response shape includes **`id`**, **`status`**, **`metadata`** (title, description, type, unit, visualization, aggregation type, and **`filter`** when configured), and **`result`**. The shape of **`result`** depends on the aggregation type: - **`statusGrouped`**: counts per threshold rule, **`total`**, **`thresholds`**, **`entitiesConsidered`**, **`calculationErrorCount`**, **`timestamp`**. - **`weightedStatusScore`**: same as status-grouped, plus **`weightedStatusScore`** (portfolio percentage in \[0, 100\], one decimal), **`weightedStatusSum`**, **`weightedStatusMaxPossible`**, and **`aggregationChartDisplayColor`** (see backend README). The homepage card shows a donut gauge for this type instead of a multi-slice status pie. - **Scalar types** (`sum`, `average`, `max`, `min`, `count`): see [Scalar result fields](#scalar-result-fields) below. When **`filter.status`** is configured, **`metadata.filter`** is also returned. +For a daily history of a **scalar** KPI over owned entities, see [`GET /aggregations/:aggregationId/time-series`](#get-aggregationsaggregationidtime-series). + ### Scalar result fields When **`metadata.aggregationType`** is one of **`sum`**, **`average`**, **`max`**, **`min`**, or **`count`**, **`result`** is a scalar aggregation payload: @@ -305,9 +339,181 @@ Always read **`value`** together with **`total`**. When nothing contributed, SQL - Response **`{ "value": 0, "total": 5 }`** means you own 5 components currently in **`error`** status whose open-PR values contributed, and the minimum among them is **`0`**. - Response **`{ "value": 0, "total": 0 }`** means no **`error`**-status rows contributed — show “no data”, not “min is 0”. +### `GET /aggregations/:aggregationId/time-series` + +Returns a **daily history** of a **scalar** KPI (`sum`, `average`, `max`, `min`, `count`) across entities you own. + +Each response point is one UTC day: Scorecard takes **latest stored row** for each owned entity that day (including calculation failures), then rolls successful values up with the KPI’s aggregation type. UTC days with no rows are omitted; a day with only failures is included with **`value: null`**, **`status: error`** and **`errors`** list. + +If the KPI has optional **`filter.status`**, only successes whose stored **`status`** matches that key contribute to **`value`**. Calculation errors are still included on the point. + +Only [scalar](#scalar-types) KPIs are supported. **`statusGrouped`** and **`weightedStatusScore`** return **`400 Bad Request`**. A metric id with no KPI block defaults to **`statusGrouped`** unless the metric’s **`defaultVisualization`** is **`sparkline`** (then **`average`**). + +#### Path parameters + +| Parameter | Type | Required | Description | +| --------------- | ------ | -------- | -------------------------------------------------------------------------------------------------- | +| `aggregationId` | string | Yes | KPI key under **`scorecard.aggregationKPIs`**, or a **metric id** when no KPI block is configured. | + +#### Query parameters + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `from` | string | Yes | Inclusive range start (ISO-8601 datetime, for example `2024-01-01T00:00:00.000Z`). | +| `to` | string | Yes | Inclusive range end (ISO-8601 datetime). Must be **greater than or equal to** **`from`**. Maximum span is **365 days**. | + +Invalid or missing query parameters return **`400 Bad Request`** (`InputError`). + +#### Permissions + +Requires: + +- **`scorecard.metric.read`** on the KPI's underlying metric +- **`catalog.entity.read`** for each entity included in the aggregation + +#### Error handling + +| Condition | Status | Notes | +| ----------------------------- | ------------------ | ----------------------------------------------------------------------------- | +| Metric access denied | `403 Forbidden` | User cannot read the metric | +| Missing credentials | `401 Unauthorized` | `AuthenticationError` | +| Missing user entity reference | `401 Unauthorized` | `AuthenticationError` | +| Invalid query params | `400 Bad Request` | Format `ISO-8601`, `from` <= `to`, maximum span is 365 days | +| Unsupported aggregation type | `400 Bad Request` | `statusGrouped` and `weightedStatusScore` aggregation types are not supported | + +#### Response + +| Field | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **`id`** | Aggregation id (KPI key or metric id). | +| **`metricId`** | Backing metric id. | +| **`metadata`** | Same metadata as the snapshot route (`title`, `description`, `type`, `unit`, `visualization`, `aggregationType`, and `filter` when configured). | +| **`points`** | List of UTC days that have at least one stored row. Each point has **`value`** (or **`null` when `successCount` is 0**), **`successCount`**, **`errorCount`**, **`total`**, **`status`** (`success` / `error`), optional **`errors`**, and **`timestamp`** (latest sample time among rows that contribute to that day or errors that day). | +| **`thresholds`** | Number-style rules for classifying **`value`**; from KPI **`options.thresholds`** or **`DEFAULT_NUMBER_THRESHOLDS`** when omitted. Not entity annotation overrides. | +| **`aggregationChartDisplayColor`** | Color of the sparkline stroke from the **last successful** point’s matching threshold rule **`color`**. **`null`** when no day has a value or the matching rule has no **`color`**. | + +#### How each day is computed + +- **Latest sample per entity per UTC day:** the stored row with the highest `id` that day, **including calculation failures**. +- **`successCount` / `value`:** entities whose latest row that day has a real value. Optional **`filter.status`** applies only to these successes. +- **`errorCount` / `errors`:** entities whose latest row that day is a calculation failure (`error_message` set and value missing). **`errors`** lists unique messages with how many entities reported each. It is **omitted** when there are none. +- **`status`:** `success` if `successCount > 0`; `error` if only calculation failures. **`value`** is **`null`** unless `status` is `success`. +- **`total`:** `successCount + errorCount` (entities that reported that day). Homepage scorecard entities current health is reported from the **last point** in `points` (`successCount` / `total`). + +#### Empty / missing days + +- **`points` is sparse:** UTC days with **no stored rows** are omitted. The UI treats a gap in `[from, to]` as no data. +- A day with only calculation errors **is** included (`status: 'error'`, **`value: null`**, **`errors`**). +- No owned entities, or no rows in range: **`200`** with **`points: []`**. + +#### Example request and response + +KPI configuration: + +```yaml +scorecard: + aggregationKPIs: + avgDeploymentFrequency: + title: Average Deployment Frequency + description: This KPI provides average weekly production deploys over a 30-day window per entity. + type: average + metricId: dora.deploymentFrequency + options: + thresholds: + rules: + - key: elite + expression: '>=7' + color: success.main + icon: scorecardSuccessStatusIcon + - key: medium + expression: '1-7' + color: warning.main + icon: scorecardWarningStatusIcon + - key: error + expression: '<1' + color: error.main + icon: scorecardErrorStatusIcon + # Optional status filter + # filter: + # status: elite +``` + +```bash +curl -X GET "{{url}}/api/scorecard/aggregations/avgDeploymentFrequency/time-series?from=2026-08-24T00:00:00.000Z&to=2026-08-24T23:59:59.999Z" \ + -H "Authorization: Bearer " +``` + +Latest data for **2026-08-24** per entity: successes `10` (elite), `14` (elite), `3` (medium), `0.2` (low); errors `timeout` ×1 and `GitHub API error` ×2. + +| KPI | `value` | `successCount` | `errorCount` | `total` | `status` | `errors` | +| ----------------------------- | --------------------------- | -------------- | ------------ | ------- | -------- | ----------- | +| `avgDeploymentFrequency` | `(10+14+3+0.2)/4` = **6.8** | 4 | 3 | 7 | `medium` | both errors | +| `avgEliteDeploymentFrequency` | `(10+14)/2` = **12** | 2 | 3 | 5 | `elite` | both errors | + +Example response for KPI without filter and one UTC day 2026-08-24: + +```json +{ + "id": "avgDeploymentFrequency", + "metricId": "dora.deploymentFrequency", + "metadata": { + "title": "Average Deployment Frequency", + "description": "This KPI provides average weekly production deploys over a 30-day window per entity.", + "type": "number", + "unit": "/week", + "history": true, + "visualization": "sparkline", + "aggregationType": "average" + }, + "points": [ + { + "value": 6.8, + "successCount": 4, + "errorCount": 3, + "total": 7, + "status": "success", + "timestamp": "2026-08-24T00:00:00.000Z", + "errors": [ + { "message": "GitHub API error", "count": 2 }, + { "message": "timeout", "count": 1 } + ] + } + ], + "thresholds": { + "rules": [ + { + "key": "elite", + "expression": ">=7", + "color": "success.main", + "icon": "scorecardSuccessStatusIcon" + }, + { + "key": "medium", + "expression": "1-7", + "color": "warning.main", + "icon": "scorecardWarningStatusIcon" + }, + { + "key": "error", + "expression": "<1", + "color": "error.main", + "icon": "scorecardErrorStatusIcon" + } + ] + }, + "aggregationChartDisplayColor": "warning.main" +} +``` + ### `GET /aggregations/:aggregationId/metadata` -Same resolution as above, but returns only metadata fields (no aggregate counts), including **`filter`** when configured on a scalar KPI. Useful for UIs that list KPIs without loading full aggregation data. +Same **`aggregationId`** resolution as [`GET /aggregations/:aggregationId`](#get-aggregationsaggregationid), but returns only metadata (no aggregate counts or time-series points), including **`filter`** when configured on a scalar KPI. Use this for UIs that list KPIs without loading full aggregation data. + +#### Path parameters + +| Parameter | Type | Required | Description | +| --------------- | ------ | -------- | -------------------------------------------------------------------------------------------------- | +| `aggregationId` | string | Yes | KPI key under **`scorecard.aggregationKPIs`**, or a **metric id** when no KPI block is configured. | #### Permissions and errors @@ -319,7 +525,7 @@ Same resolution as above, but returns only metadata fields (no aggregate counts) #### Empty results -When the user owns no relevant entities, distribution types (**`statusGrouped`**, **`weightedStatusScore`**) return **zero total** and zeroed bucket counts (not an error). For scalar empty / filtered-empty handling — including why **`value: 0`** with **`total: 0`** is ambiguous for **`min`** / **`max`** — see [Interpreting scalar results](#interpreting-scalar-results). +When the user owns no relevant entities, snapshot distribution types (**`statusGrouped`**, **`weightedStatusScore`**) return **zero total** and zeroed bucket counts (not an error). For scalar empty / filtered-empty handling — including why **`value: 0`** with **`total: 0`** is ambiguous for **`min`** / **`max`** — see [Interpreting scalar results](#interpreting-scalar-results). Scalar time-series **omits** UTC days with no rows (`points` may be `[]`). ### Drill-down vs aggregation id @@ -406,7 +612,7 @@ If the user doesn't have access to the specified metric: 4. **Group Structure**: Be aware of the direct parent group limitation when designing your group hierarchy. You currently receive scorecard results only for entities you own and those of your immediate parent group. To include results from _all_ parent groups, you can either implement custom logic, restructure your groups, or (if using RHDH), enable transitive parent groups ([see transitive parent group enablement documentation](https://docs.redhat.com/en/documentation/red_hat_developer_hub/1.5/html-single/authorization_in_red_hat_developer_hub/index#enabling-transitive-parent-groups)). -5. **Metric access**: Aggregation routes enforce **`scorecard.metric.read`** for the underlying metric and **`catalog.entity.read`** for each included entity; expect **`403 Forbidden`** when either check fails. +5. **Metric access**: Aggregation snapshot and time-series routes enforce **`scorecard.metric.read`** for the underlying metric and **`catalog.entity.read`** for each included entity; expect **`403 Forbidden`** when either check fails. For RBAC, scheduling, full endpoint reference, and **app-config examples** for **`weightedStatusScore`** and **scalar** KPIs, see the [Scorecard backend README](../README.md). diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts index 39caff6060c..de0eb35b13c 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/getEntityMetrics.ts @@ -62,7 +62,7 @@ export const createGetEntityMetricsAction = ({ type: z.enum(['number', 'boolean']), unit: z.string().optional(), history: z.boolean().optional(), - defaultVisualization: z.enum(['value', 'sparkline']).optional(), + defaultVisualization: z.enum(['donut', 'sparkline']).optional(), collectorIds: z.array(z.string()).optional(), }), result: z.object({ diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts index 7d9ec11fd82..fd1dc98c3b0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.test.ts @@ -49,7 +49,7 @@ describe('createListMetricsAction', () => { title: 'Code Coverage', description: 'Test coverage percentage', type: 'number' as const, - defaultVisualization: 'value' as const, + defaultVisualization: 'donut' as const, }, ]; (mockRegistry.listMetrics as jest.Mock).mockReturnValue(metrics); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts index 2daf3f9dc8b..c82d9cbcf86 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/actions/listMetrics.ts @@ -53,7 +53,7 @@ export const createListMetricsAction = ({ type: z.enum(['number', 'boolean']), unit: z.string().optional(), history: z.boolean().optional(), - defaultVisualization: z.enum(['value', 'sparkline']).optional(), + defaultVisualization: z.enum(['donut', 'sparkline']).optional(), collectorIds: z.array(z.string()).optional(), }), ), diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts index 873fa9610b4..d5d9016070d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.test.ts @@ -20,7 +20,11 @@ import { TestDatabases, } from '@backstage/backend-test-utils'; import { DatabaseMetricValues } from './DatabaseMetricValues'; -import { DbMetricValueCreate } from './types'; +import { + DbMetricValueCreate, + DbScalarTimeSeriesPoint, + ScalarAggregationFn, +} from './types'; import { toMetricValueRow } from './utils/mapMetricValueRow'; import { migrate } from './migration'; @@ -2249,4 +2253,1203 @@ describe('DatabaseMetricValues', () => { }, ); }); + + describe('readScalarAggregatedMetricTimeSeriesByEntityRefs', () => { + const entityRefs = [ + 'component:default/a', + 'component:default/b', + 'component:default/c', + ]; + const from = new Date('2024-01-01T00:00:00Z'); + const to = new Date('2024-01-02T23:59:59Z'); + + it.each(databases.eachSupportedId())( + 'should filter in [from, to] range - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 1, + timestamp: new Date('2024-01-01T23:59:59Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 7, + timestamp: new Date('2024-01-02T00:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 13, + timestamp: new Date('2024-01-03T10:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 28, + timestamp: new Date('2024-01-04T00:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + new Date('2024-01-02T00:00:00Z'), + new Date('2024-01-03T23:59:59Z'), + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-02T00:00:00Z'), + value: 7, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + { + maxTimestamp: new Date('2024-01-03T10:00:00Z'), + value: 13, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by catalog entity refs and metricId - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + metricId: 'github:otherMetric', + value: 10, + timestamp: new Date('2024-01-01T08:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 14, + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 1, + timestamp: new Date('2024-01-01T10:30:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-01T23:59:59Z'), + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T10:00:00Z'), + value: 14, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use the latest row per UTC day when multiple success samples - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + timestamp: new Date('2024-01-01T08:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 14, + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 1, + timestamp: new Date('2024-01-01T10:30:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 20, // A latest + timestamp: new Date('2024-01-01T11:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 7, // B latest + timestamp: new Date('2024-01-01T11:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', // A next day + value: 40, + timestamp: new Date('2024-01-02T10:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a', 'component:default/b'], + 'github.metric1', + 'sum', + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-03T23:59:59Z'), + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T11:00:00Z'), + value: 27, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + { + maxTimestamp: new Date('2024-01-02T10:00:00Z'), + value: 40, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return max timestamp across all values in a day point - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + const aBaseTimestamp = new Date('2024-01-01T08:00:00Z'); + const aLastTimestamp = new Date('2024-01-01T20:00:00Z'); + const bLaterTimestampLastInserted = new Date('2024-01-01T15:00:00Z'); + + await client('metric_values').insert( + [ + // earlier A value, not in aggregation + createMetricValue({ + entityRef: 'component:default/a', + value: 1, + timestamp: aBaseTimestamp, + }), + // last A value + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + timestamp: aLastTimestamp, + }), + // last B value, timestamp before A + createMetricValue({ + entityRef: 'component:default/b', + value: 3, + timestamp: bLaterTimestampLastInserted, + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a', 'component:default/b'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: aLastTimestamp, + value: 13, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return max timestamp across all values in a day point including calculation errors - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + const aBaseTimestamp = new Date('2024-01-01T08:00:00Z'); + const aLastErrorTimestamp = new Date('2024-01-01T20:00:00Z'); + const bLaterTimestampLastInserted = new Date('2024-01-01T15:00:00Z'); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 1, + timestamp: aBaseTimestamp, + }), + createMetricValue({ + entityRef: 'component:default/a', + value: null, + errorMessage: 'boom', + status: null, + timestamp: aLastErrorTimestamp, + }), + createMetricValue({ + entityRef: 'component:default/b', + value: null, + errorMessage: 'timeout', + status: null, + timestamp: bLaterTimestampLastInserted, + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a', 'component:default/b'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: aLastErrorTimestamp, + value: null, + successCount: 0, + errorCount: 2, + total: 2, + errors: [ + { message: 'boom', count: 1 }, + { message: 'timeout', count: 1 }, + ], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use the latest row per UTC day when multiple samples and latest is calculation error - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + timestamp: new Date('2024-01-01T08:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: null, + errorMessage: 'boom', + status: null, + timestamp: new Date('2024-01-01T18:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-01T23:59:59Z'), + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T18:00:00Z'), + value: null, + successCount: 0, + errorCount: 1, + total: 1, + errors: [{ message: 'boom', count: 1 }], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should bucket by UTC day when Postgres session TimeZone is non-UTC - %p', + async databaseId => { + if (databaseId !== 'POSTGRES_15') { + return; + } + + const { client } = await createDatabase(databaseId); + + // Knex dateTime is timestamptz. TO_CHAR(timestamptz) uses session + // TimeZone. These instants are the same UTC day (2026-04-28) but + // different America/New_York calendar days (EDT = UTC-4; local + // midnight is 04:00Z). Keep SET LOCAL and the query on one connection. + await client.transaction(async trx => { + await trx.raw(`SET LOCAL TimeZone TO 'America/New_York'`); + + await trx('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 1, + timestamp: new Date('2026-04-28T03:30:00.000Z'), // 27 Apr 23:30 EDT + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 2, + timestamp: new Date('2026-04-28T04:30:00.000Z'), // 28 Apr 00:30 EDT + }), + ].map(toMetricValueRow), + ); + + const result: DbScalarTimeSeriesPoint[] = + await new DatabaseMetricValues( + trx, + ).readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a', 'component:default/b'], + 'github.metric1', + 'sum', + new Date('2026-04-27T00:00:00.000Z'), + new Date('2026-04-29T00:00:00.000Z'), + ); + + // TO_CHAR uses UTC TimeZone instead of session TimeZone + expect(result).toEqual([ + { + maxTimestamp: new Date('2026-04-28T04:30:00.000Z'), + value: 3, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ]); + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return empty points when entity refs list is empty - %p', + async databaseId => { + const { db } = await createDatabase(databaseId); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + [], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return empty points when no rows exist in range - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 1, + timestamp: new Date('2023-12-31T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should handle days with only calculation errors for every aggregation function - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: null, + errorMessage: 'boom', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const aggregationFunctions: ScalarAggregationFn[] = [ + 'sum', + 'average', + 'count', + 'max', + 'min', + ]; + + for (const aggregationFn of aggregationFunctions) { + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + aggregationFn, + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: null, + successCount: 0, + errorCount: 1, + total: 1, + errors: [{ message: 'boom', count: 1 }], + }, + ]); + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should group distinct error messages and sort by count then message - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: null, + errorMessage: 'boom', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/c', + value: null, + errorMessage: 'timeout', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/d', + value: null, + errorMessage: 'error', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/e', + value: null, + errorMessage: 'timeout', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + [ + 'component:default/a', + 'component:default/b', + 'component:default/c', + 'component:default/d', + 'component:default/e', + ], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 10, + successCount: 1, + errorCount: 4, + total: 5, + errors: [ + { message: 'timeout', count: 2 }, + { message: 'boom', count: 1 }, + { message: 'error', count: 1 }, + ], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should omit days whose latest rows are missing value with no error_message - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: null, + errorMessage: null, + status: 'success', + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 4, + timestamp: new Date('2024-01-02T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-02T12:00:00Z'), + value: 4, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should treat JSON null value with error_message as a calculation error - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert([ + { + catalog_entity_ref: 'component:default/a', + metric_id: 'github.metric1', + value: 'null', + timestamp: new Date('2024-01-01T12:00:00Z'), + error_message: 'boom', + status: null, + }, + ]); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: null, + successCount: 0, + errorCount: 1, + total: 1, + errors: [{ message: 'boom', count: 1 }], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should not treat error_message with a present value as a calculation error - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 8, + errorMessage: 'threshold config invalid', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 8, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should treat 0 as a successful value, not missing - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 0, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'github.metric1', + 'sum', + from, + to, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 0, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ]); + }, + ); + + describe.each(databases.eachSupportedId())( + 'aggregate latest value per entity per UTC day - %p', + databaseId => { + let db: DatabaseMetricValues; + + beforeAll(async () => { + const database = await createDatabase(databaseId); + const { client } = database; + db = database.db; + + const day1 = new Date('2024-01-01T12:00:00Z'); + const day1Later = new Date('2024-01-01T18:00:00Z'); + const day2 = new Date('2024-01-02T12:00:00Z'); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + timestamp: day1, + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 20, + timestamp: day1Later, + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 40, + timestamp: day1, + }), + createMetricValue({ + entityRef: 'component:default/c', + value: null, + errorMessage: 'boom', + status: null, + timestamp: day1, + }), + createMetricValue({ + entityRef: 'component:default/a', + value: 5, + timestamp: day2, + }), + createMetricValue({ + entityRef: 'component:default/b', + value: null, + errorMessage: 'boom', + status: null, + timestamp: day2, + }), + ].map(toMetricValueRow), + ); + }); + + it.each([ + [ + 'sum', + [ + { + maxTimestamp: new Date('2024-01-01T18:00:00Z'), + value: 60, + successCount: 2, + errorCount: 1, + total: 3, + errors: [{ message: 'boom', count: 1 }], + }, + { + maxTimestamp: new Date('2024-01-02T12:00:00Z'), + value: 5, + successCount: 1, + errorCount: 1, + total: 2, + errors: [{ message: 'boom', count: 1 }], + }, + ], + ], + [ + 'average', + [ + { + maxTimestamp: new Date('2024-01-01T18:00:00Z'), + value: 30, + successCount: 2, + errorCount: 1, + total: 3, + errors: [{ message: 'boom', count: 1 }], + }, + { + maxTimestamp: new Date('2024-01-02T12:00:00Z'), + value: 5, + successCount: 1, + errorCount: 1, + total: 2, + errors: [{ message: 'boom', count: 1 }], + }, + ], + ], + [ + 'count', + [ + { + maxTimestamp: new Date('2024-01-01T18:00:00Z'), + value: 2, + successCount: 2, + errorCount: 1, + total: 3, + errors: [{ message: 'boom', count: 1 }], + }, + { + maxTimestamp: new Date('2024-01-02T12:00:00Z'), + value: 1, + successCount: 1, + errorCount: 1, + total: 2, + errors: [{ message: 'boom', count: 1 }], + }, + ], + ], + [ + 'max', + [ + { + maxTimestamp: new Date('2024-01-01T18:00:00Z'), + value: 40, + successCount: 2, + errorCount: 1, + total: 3, + errors: [{ message: 'boom', count: 1 }], + }, + { + maxTimestamp: new Date('2024-01-02T12:00:00Z'), + value: 5, + successCount: 1, + errorCount: 1, + total: 2, + errors: [{ message: 'boom', count: 1 }], + }, + ], + ], + [ + 'min', + [ + { + maxTimestamp: new Date('2024-01-01T18:00:00Z'), + value: 20, + successCount: 2, + errorCount: 1, + total: 3, + errors: [{ message: 'boom', count: 1 }], + }, + { + maxTimestamp: new Date('2024-01-02T12:00:00Z'), + value: 5, + successCount: 1, + errorCount: 1, + total: 2, + errors: [{ message: 'boom', count: 1 }], + }, + ], + ], + ] as const)( + 'should %s across UTC days', + async (aggregationFn, expected) => { + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + entityRefs, + 'github.metric1', + aggregationFn, + from, + to, + ); + + expect(result).toEqual(expected); + }, + ); + }, + ); + + describe.each(databases.eachSupportedId())( + 'filter.status - %p', + databaseId => { + let db: DatabaseMetricValues; + + beforeAll(async () => { + const database = await createDatabase(databaseId); + const { client } = database; + db = database.db; + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + status: 'error', + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 40, + status: 'success', + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/c', + value: 25, + status: 'error', + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + }); + + it.each([ + [ + 'sum', + 'error', + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 35, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ], + [ + 'count', + 'error', + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 2, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ], + [ + 'max', + 'error', + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 25, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ], + [ + 'min', + 'error', + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 10, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ], + [ + 'average', + 'error', + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 17.5, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ], + [ + 'sum', + 'success', + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 40, + successCount: 1, + errorCount: 0, + total: 1, + errors: [], + }, + ], + ] as const)( + 'should %s only rows matching filter.status=%s', + async (aggregationFn, status, expected) => { + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + entityRefs, + 'github.metric1', + aggregationFn, + from, + to, + { status }, + ); + + expect(result).toEqual([expected]); + }, + ); + + it.each(['sum', 'average', 'count', 'max', 'min'] as const)( + 'should omit the day when no rows match filter.status for %s', + async aggregationFn => { + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + entityRefs, + 'github.metric1', + aggregationFn, + from, + to, + { status: 'warning' }, + ); + + expect(result).toEqual([]); + }, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should keep calculation errors when filter.status excludes their null status - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + status: 'error', + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 40, + status: 'success', + timestamp: new Date('2024-01-01T13:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/c', + value: null, + errorMessage: 'boom', + status: null, + timestamp: new Date('2024-01-01T12:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + entityRefs, + 'github.metric1', + 'sum', + from, + to, + { status: 'error' }, + ); + + expect(result).toEqual([ + { + maxTimestamp: new Date('2024-01-01T12:00:00Z'), + value: 10, + successCount: 1, + errorCount: 1, + total: 2, + errors: [{ message: 'boom', count: 1 }], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return empty points when filter.status matches no successes and there are no calculation errors - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + status: 'success', + timestamp: new Date('2024-01-01T20:00:00Z'), + }), + createMetricValue({ + entityRef: 'component:default/b', + value: 40, + status: 'error', + timestamp: new Date('2024-01-01T18:00:00Z'), + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + ['component:default/a', 'component:default/b'], + 'github.metric1', + 'sum', + from, + to, + { status: 'warning' }, + ); + + expect(result).toEqual([]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use max timestamp from calculation errors when filter.status excludes all successes - %p', + async databaseId => { + const { client, db } = await createDatabase(databaseId); + + const excludedSuccessTimestamp = new Date('2024-01-01T20:00:00Z'); + const errorTimestamp = new Date('2024-01-01T10:00:00Z'); + const errorLaterTimestamp = new Date('2024-01-01T11:00:00Z'); + + await client('metric_values').insert( + [ + createMetricValue({ + entityRef: 'component:default/a', + value: 10, + status: 'success', + timestamp: excludedSuccessTimestamp, + }), + createMetricValue({ + entityRef: 'component:default/b', + value: null, + errorMessage: 'boom', + status: null, + timestamp: errorLaterTimestamp, + }), + createMetricValue({ + entityRef: 'component:default/c', + value: null, + errorMessage: 'boom', + status: null, + timestamp: errorTimestamp, + }), + ].map(toMetricValueRow), + ); + + const result = + await db.readScalarAggregatedMetricTimeSeriesByEntityRefs( + [ + 'component:default/a', + 'component:default/b', + 'component:default/c', + ], + 'github.metric1', + 'sum', + from, + to, + { status: 'error' }, + ); + + expect(result).toEqual([ + { + maxTimestamp: errorLaterTimestamp, + value: null, + successCount: 0, + errorCount: 2, + total: 2, + errors: [{ message: 'boom', count: 2 }], + }, + ]); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts index bac9c233854..f80db49c4b1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/DatabaseMetricValues.ts @@ -21,6 +21,7 @@ import { DbMetricValue, DbAggregatedMetric, DbScalarAggregatedMetric, + DbScalarTimeSeriesPoint, ScalarAggregationFn, } from './types'; import { normalizeTimestamp } from '../utils/normalizeTimestamp'; @@ -31,6 +32,10 @@ import { toMetricValueRow, type MetricValueRowWithId, } from './utils/mapMetricValueRow'; +import { + buildScalarTimeSeriesPoints, + type DbScalarTimeSeriesQueryRow, +} from './utils/buildScalarTimeSeriesPoints'; type ReadEntityMetricsWithFiltersOptions = { status?: string; @@ -65,6 +70,14 @@ type ScalarAggregationRowResult = { export class DatabaseMetricValues { private readonly tableName = 'metric_values'; + private readonly dbClient: Knex; + private readonly isPostgres: boolean; + + constructor(dbClient: Knex) { + this.dbClient = dbClient; + const clientName: string = (dbClient as any).client?.config?.client ?? ''; + this.isPostgres = clientName === 'pg' || clientName.includes('postgres'); + } /** * `value` is a JSON column. Depending on database/driver, a "missing" metric value can @@ -73,12 +86,23 @@ export class DatabaseMetricValues { private static readonly metricValueIsMissingExpr = "(value IS NULL OR CAST(value AS TEXT) = 'null')"; - constructor(private readonly dbClient: Knex) {} + /** + * UTC calendar day as `YYYY-MM-DD`. + * Postgres: Knex dateTime is timestamptz on Postgres. TO_CHAR(timestamptz, ...) formats in + * the session TimeZone, so non-UTC sessions bucket by local calendar day. Convert + * to UTC wall-clock first so grouping matches Date#getUTC* (UTC sessions unchanged). + * SQLite: stores Unix milliseconds. + */ + private getUtcDayExpr(): string { + return this.isPostgres + ? "TO_CHAR(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD')" + : "strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch')"; + } - private get isPostgres(): boolean { - const clientName: string = - (this.dbClient as any).client?.config?.client ?? ''; - return clientName === 'pg' || clientName.includes('postgres'); + private getNumericValueExpr(): string { + return this.isPostgres + ? 'CAST(value::text AS DOUBLE PRECISION)' + : 'CAST(CAST(value AS TEXT) AS REAL)'; } /** @@ -95,6 +119,46 @@ export class DatabaseMetricValues { .groupBy('catalog_entity_ref'); } + /** + * Get the latest ids subquery in time range per UTC calendar day for a metric + * and each entity in catalogEntityRefs. + * + * For each UTC day in `[from, to]` and catalogEntity in catalogEntityRefs: picks + * the sample with the highest `id` among rows that are either a real value or a + * calculation error. Days with only null-without-error rows are omitted. + * @param catalogEntityRefs An array of catalog entity references to filter the metric values by. + * @param metricId The ID of the metric to retrieve latest IDs for. + * @param from The start of the time range (inclusive). + * @param to The end of the time range (inclusive). + * @returns Knex QueryBuilder is resolving to [{ id: NUM, utc_day: 'YYYY-MM-DD' }] + */ + private getLatestIdsPerUtcDaySubquery( + catalogEntityRefs: string[], + metricId: string, + from: Date, + to: Date, + ): Knex.QueryBuilder { + const utcDayExpr = this.getUtcDayExpr(); + + const missing = DatabaseMetricValues.metricValueIsMissingExpr; + const chosenIdExpr = `MAX(CASE + WHEN NOT ${missing} OR (error_message IS NOT NULL AND ${missing}) + THEN id + END)`; + + return this.dbClient(this.tableName) + .select( + this.dbClient.raw(`${chosenIdExpr} as id`), + this.dbClient.raw(`${utcDayExpr} as utc_day`), + ) + .where('metric_id', metricId) + .whereIn('catalog_entity_ref', catalogEntityRefs) + .where('timestamp', '>=', from) + .where('timestamp', '<=', to) + .groupByRaw(`catalog_entity_ref, ${utcDayExpr}`) + .havingRaw(`${chosenIdExpr} IS NOT NULL`); + } + /** * Get the stats row for a given latest ids subquery */ @@ -141,9 +205,7 @@ export class DatabaseMetricValues { aggregationFn: ScalarAggregationFn, filter?: AggregationConfigFilter, ): Promise { - const numericValueExpr = this.isPostgres - ? 'CAST(value::text AS DOUBLE PRECISION)' - : 'CAST(CAST(value AS TEXT) AS REAL)'; + const numericValueExpr = this.getNumericValueExpr(); const aggregateExpression = getAggregateExpression( aggregationFn, @@ -245,12 +307,7 @@ export class DatabaseMetricValues { from: Date, to: Date, ): Promise { - // Knex dateTime is timestamptz on Postgres. TO_CHAR(timestamptz, ...) formats in - // the session TimeZone, so non-UTC sessions bucket by local calendar day. Convert - // to UTC wall-clock first so grouping matches Date#getUTC* (UTC sessions unchanged). - const utcDayExpr = this.isPostgres - ? "TO_CHAR(timestamp AT TIME ZONE 'UTC', 'YYYY-MM-DD')" - : "strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch')"; + const utcDayExpr = this.getUtcDayExpr(); const missing = DatabaseMetricValues.metricValueIsMissingExpr; const chosenIdExpr = `MAX(CASE @@ -404,6 +461,97 @@ export class DatabaseMetricValues { }; } + /** + * Scalar aggregation of the latest row per entity per UTC day (success and errors). + * Days with no stored rows are omitted. Ordered by utc_day. + * + * Query plan (single round-trip): + * 1. `latest_ids`: latest id per (entity, UTC day) in [from, to]. + * 2. `daily`: aggregated value / successCount / errorCount / max timestamp grouped by that utc_day. + * Optional `filter.status` keeps matching successes and all calculation errors. + * 3. `error_counts`: unique error_message counts, left-joined onto daily. + */ + async readScalarAggregatedMetricTimeSeriesByEntityRefs( + catalogEntityRefs: string[], + metricId: string, + aggregationFn: ScalarAggregationFn, + from: Date, + to: Date, + filter?: AggregationConfigFilter, + ): Promise { + if (catalogEntityRefs.length === 0) { + return []; + } + + const missingExpr = DatabaseMetricValues.metricValueIsMissingExpr; + const calculationErrorExpr = `error_message IS NOT NULL AND ${missingExpr}`; + const successSql = `NOT ${missingExpr}`; + const aggregateExpression = getAggregateExpression( + aggregationFn, + this.getNumericValueExpr(), + successSql, + ); + + const latestIdsPerEntityPerUTCDay = this.getLatestIdsPerUtcDaySubquery( + catalogEntityRefs, + metricId, + from, + to, + ); + + const dailyAggregateQuery = this.dbClient(this.tableName) + .innerJoin('latest_ids', `${this.tableName}.id`, 'latest_ids.id') + .select( + 'latest_ids.utc_day as utc_day', + this.dbClient.raw(`${aggregateExpression} as value`), + this.dbClient.raw( + `COUNT(CASE WHEN ${successSql} THEN 1 END) as success_count`, + ), + this.dbClient.raw( + `SUM(CASE WHEN ${calculationErrorExpr} THEN 1 ELSE 0 END) as error_count`, + ), + this.dbClient.raw('MAX(timestamp) as max_timestamp'), + ) + .groupBy('latest_ids.utc_day'); + + // Apply filter to aggregation (filter rows by status OR include them if they have error) + if (filter?.status && filter.status !== '') { + dailyAggregateQuery.where(qb => { + qb.where('status', filter.status).orWhereRaw(calculationErrorExpr); + }); + } + + const errorCounts = this.dbClient(this.tableName) + .innerJoin('latest_ids', `${this.tableName}.id`, 'latest_ids.id') + .whereRaw(calculationErrorExpr) + .select( + 'latest_ids.utc_day as utc_day', + 'error_message', + this.dbClient.raw('COUNT(*) as count'), + ) + .groupBy('latest_ids.utc_day', 'error_message'); + + const rows = await this.dbClient + .with('latest_ids', latestIdsPerEntityPerUTCDay) + .with('daily', dailyAggregateQuery) + .with('error_counts', errorCounts) + .from('daily') + .leftJoin('error_counts', 'daily.utc_day', 'error_counts.utc_day') + .select( + 'daily.utc_day as utc_day', + 'daily.value as value', + 'daily.success_count as success_count', + 'daily.error_count as error_count', + this.dbClient.raw('(daily.success_count + daily.error_count) as total'), + 'daily.max_timestamp as max_timestamp', + 'error_counts.error_message as error_message', + this.dbClient.raw('error_counts.count as error_msg_count'), + ) + .orderBy('daily.utc_day', 'asc'); + + return buildScalarTimeSeriesPoints(rows as DbScalarTimeSeriesQueryRow[]); + } + /** * Fetch the latest entity metric values for a given metric, with optional filtering * by status, name, kind, namespace, or owner, plus sorting and pagination. diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts index 4ea0ab91b97..cf5df52c9ed 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/types.ts @@ -65,3 +65,17 @@ export type DbScalarAggregatedMetric = { calculationErrorCount: number; latestEntityCount: number; }; + +export type DbTimeSeriesPointError = { + message: string; + count: number; +}; + +export type DbScalarTimeSeriesPoint = { + maxTimestamp: Date; + value: number | null; + successCount: number; + errorCount: number; + total: number; + errors: DbTimeSeriesPointError[]; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/buildScalarTimeSeriesPoints.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/buildScalarTimeSeriesPoints.test.ts new file mode 100644 index 00000000000..8c0b46d6441 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/buildScalarTimeSeriesPoints.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { buildScalarTimeSeriesPoints } from './buildScalarTimeSeriesPoints'; + +describe('buildScalarTimeSeriesPoints', () => { + it('groups error-message rows onto one point per UTC day', () => { + expect( + buildScalarTimeSeriesPoints([ + { + utc_day: '2024-01-01', + max_timestamp: '2024-01-01T18:00:00.000Z', + value: 10, + success_count: 2, + error_count: 3, + total: 5, + error_message: 'timeout', + error_msg_count: 2, + }, + { + utc_day: '2024-01-01', + max_timestamp: '2024-01-01T18:00:00.000Z', + value: 10, + success_count: 2, + error_count: 3, + total: 5, + error_message: 'failed to calculate', + error_msg_count: 1, + }, + ]), + ).toEqual([ + { + maxTimestamp: new Date('2024-01-01T18:00:00.000Z'), + value: 10, + successCount: 2, + errorCount: 3, + total: 5, + errors: [ + { message: 'timeout', count: 2 }, + { message: 'failed to calculate', count: 1 }, + ], + }, + ]); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/buildScalarTimeSeriesPoints.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/buildScalarTimeSeriesPoints.ts new file mode 100644 index 00000000000..7d3be8eeeff --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/buildScalarTimeSeriesPoints.ts @@ -0,0 +1,78 @@ +/* + * 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 { DbScalarTimeSeriesPoint } from '../types'; +import { parseTimestamp } from '../../utils/normalizeTimestamp'; + +/** + * One row from the scalar time-series query: + * `daily` scalar aggregation left-joined to `error_counts` aggregation). + */ +export type DbScalarTimeSeriesQueryRow = { + utc_day: string; + max_timestamp: Date | string | number; + value: number | string | null; + success_count: number | string; + error_count: number | string; + total: number | string; + error_message: string | null; + error_msg_count: number | string | null; +}; + +/** + * Group left-joined query rows into one {@link DbScalarTimeSeriesPoint} per UTC day. + * Coerces driver numeric types, attaches error messages, sorts them by count then + * message. + */ +export function buildScalarTimeSeriesPoints( + rows: DbScalarTimeSeriesQueryRow[], +): DbScalarTimeSeriesPoint[] { + const pointsByDay = new Map(); + + for (const row of rows) { + let point = pointsByDay.get(row.utc_day); + if (!point) { + const successCount = Number(row.success_count) || 0; + const errorCount = Number(row.error_count) || 0; + const rawValue = Number(row.value); + const rawTotal = Number(row.total); + point = { + maxTimestamp: parseTimestamp(row.max_timestamp), + value: successCount > 0 && Number.isFinite(rawValue) ? rawValue : null, + successCount, + errorCount, + total: Number.isFinite(rawTotal) ? rawTotal : successCount + errorCount, + errors: [], + }; + pointsByDay.set(row.utc_day, point); + } + const uniqueErrorMessage = row.error_message ?? ''; + if (uniqueErrorMessage !== '') { + point.errors.push({ + message: uniqueErrorMessage, + count: Number(row.error_msg_count) || 0, + }); + } + } + + for (const point of pointsByDay.values()) { + point.errors.sort( + (a, b) => b.count - a.count || a.message.localeCompare(b.message), + ); + } + + return Array.from(pointsByDay.values()); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts index 9c176d8d731..2d7c4a47e1e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.test.ts @@ -31,6 +31,21 @@ describe('getAggregateExpression', () => { ); }); + it.each([ + ['sum', `SUM(CASE WHEN NOT missing THEN ${numericValueExpr} END)`], + ['average', `AVG(CASE WHEN NOT missing THEN ${numericValueExpr} END)`], + ['max', `MAX(CASE WHEN NOT missing THEN ${numericValueExpr} END)`], + ['min', `MIN(CASE WHEN NOT missing THEN ${numericValueExpr} END)`], + ['count', 'COUNT(CASE WHEN NOT missing THEN 1 END)'], + ] as const)( + 'should wrap %s with a row-included CASE WHEN', + (aggregationFn, expected) => { + expect( + getAggregateExpression(aggregationFn, numericValueExpr, 'NOT missing'), + ).toBe(expected); + }, + ); + it('should throw for invalid aggregation function', () => { expect(() => getAggregateExpression('invalid' as 'sum', numericValueExpr), diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts index ba6c470bd54..3d732e47ce0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/database/utils/getAggregateExpression.ts @@ -16,21 +16,35 @@ import { ScalarAggregationFn } from '../types'; +/** + * Builds a SQL aggregate expression for a scalar function. + * + * @param aggregationFn - Scalar function (`sum`, `average`, `max`, `min`, `count`) + * @param numericValueExpr - SQL expression for the numeric metric value + * @param rowIncludedExpr - Optional SQL boolean; only matching rows are included in aggregation + */ export function getAggregateExpression( aggregationFn: ScalarAggregationFn, numericValueExpr: string, + rowIncludedExpr?: string, ): string { + const valueExpr = rowIncludedExpr + ? `CASE WHEN ${rowIncludedExpr} THEN ${numericValueExpr} END` + : numericValueExpr; + switch (aggregationFn) { case 'count': - return 'COUNT(*)'; + return rowIncludedExpr + ? `COUNT(CASE WHEN ${rowIncludedExpr} THEN 1 END)` + : 'COUNT(*)'; case 'sum': - return `SUM(${numericValueExpr})`; + return `SUM(${valueExpr})`; case 'average': - return `AVG(${numericValueExpr})`; + return `AVG(${valueExpr})`; case 'max': - return `MAX(${numericValueExpr})`; + return `MAX(${valueExpr})`; case 'min': - return `MIN(${numericValueExpr})`; + return `MIN(${valueExpr})`; default: throw new Error(`Invalid aggregation function: ${aggregationFn}`); } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts index 1976a7650c5..3c12e52d3b3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateQueryAndParams.test.ts @@ -19,7 +19,10 @@ import type { Request, Response } from 'express'; import { validateAggregationIdParam } from './validateAggregationIdParam'; import { validateMetricIdsQueryParams } from './validateMetricIdsQueryParams'; import { validateDatasourceQueryParams } from './validateDatasourceQueryParams'; -import { validateTimeSeriesQueryParams } from './validateTimeSeriesQueryParams'; +import { + validateAggregationTimeSeriesQueryParams, + validateTimeSeriesQueryParams, +} from './validateTimeSeriesQueryParams'; function mockReq(overrides: Partial = {}): Request { return { @@ -251,4 +254,72 @@ describe('Validators', () => { expect(next).not.toHaveBeenCalled(); }); }); + + describe('validateAggregationTimeSeriesQueryParams', () => { + const validQuery = { + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-31T23:59:59.000Z', + }; + + it.each([ + ['all query params are valid', validQuery], + ['from equals to', { from: validQuery.from, to: validQuery.from }], + [ + 'range is 365 days', + { from: validQuery.from, to: '2024-12-31T00:00:00.000Z' }, + ], + ])('should call next when %s', (_label, queryParams) => { + const req = mockReq({ query: { ...queryParams } }); + + validateAggregationTimeSeriesQueryParams(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['missing from', { to: validQuery.to }, 'Required'], + ['missing to', { from: validQuery.from }, 'Required'], + ['missing from and to', {}, 'Required'], + [ + 'invalid from', + { ...validQuery, from: 'not-a-date' }, + 'Invalid query parameters', + ], + [ + 'invalid to', + { ...validQuery, to: 'not-a-date' }, + 'Invalid query parameters', + ], + [ + 'from is after to', + { + from: validQuery.to, + to: validQuery.from, + }, + 'from must be less than or equal to to', + ], + [ + 'range exceeds 365 days', + { + from: validQuery.from, + to: '2025-01-01T00:00:00.001Z', + }, + 'time range must not exceed 365 days', + ], + ])( + 'should throw InputError when %s', + (_label, queryParams, expectedErrorMessage) => { + const req = mockReq({ query: { ...queryParams } }); + + expect(() => + validateAggregationTimeSeriesQueryParams(req, res, next), + ).toThrow(InputError); + expect(() => + validateAggregationTimeSeriesQueryParams(req, res, next), + ).toThrow(expectedErrorMessage); + + expect(next).not.toHaveBeenCalled(); + }, + ); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts index 44dbcf5131a..2fab9b4b352 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/middlewares/validateTimeSeriesQueryParams.ts @@ -23,30 +23,35 @@ export const MAX_TIME_SERIES_RANGE_DAYS = 365; const MAX_TIME_SERIES_RANGE_MS = MAX_TIME_SERIES_RANGE_DAYS * 24 * 60 * 60 * 1000; +const timeRangeSchema = z + .object({ + from: z.string().datetime(), + to: z.string().datetime(), + }) + .refine(data => new Date(data.from) <= new Date(data.to), { + message: 'from must be less than or equal to to', + path: ['from'], + }) + .refine( + data => + new Date(data.to).getTime() - new Date(data.from).getTime() <= + MAX_TIME_SERIES_RANGE_MS, + { + message: `time range must not exceed ${MAX_TIME_SERIES_RANGE_DAYS} days`, + path: ['to'], + }, + ); + export function validateTimeSeriesQueryParams( req: Request, _res: Response, next: NextFunction, ): void { - const schema = z - .object({ + const schema = timeRangeSchema.and( + z.object({ metricId: z.string().min(1).max(255), - from: z.string().datetime(), - to: z.string().datetime(), - }) - .refine(data => new Date(data.from) <= new Date(data.to), { - message: 'from must be less than or equal to to', - path: ['from'], - }) - .refine( - data => - new Date(data.to).getTime() - new Date(data.from).getTime() <= - MAX_TIME_SERIES_RANGE_MS, - { - message: `time range must not exceed ${MAX_TIME_SERIES_RANGE_DAYS} days`, - path: ['to'], - }, - ); + }), + ); const parsed = schema.safeParse(req.query); @@ -56,3 +61,17 @@ export function validateTimeSeriesQueryParams( next(); } + +export function validateAggregationTimeSeriesQueryParams( + req: Request, + _res: Response, + next: NextFunction, +): void { + const parsed = timeRangeSchema.safeParse(req.query); + + if (!parsed.success) { + throw new InputError(`Invalid query parameters: ${parsed.error.message}`); + } + + next(); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts index d9418962092..103b1cc2d4e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts @@ -29,7 +29,9 @@ import { } from '../__fixtures__/mockProviders'; import request from 'supertest'; import type { Server } from 'http'; -import type { Entity } from '@backstage/catalog-model'; +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; +import { knex as createKnex, type Knex } from 'knex'; +import { toMetricValueRow } from './database/utils/mapMetricValueRow'; /** * Backend module that registers mock metric providers via the extension point, @@ -68,6 +70,7 @@ const BASE_CONFIG = { function startScorecardBackend(options?: { config?: object; entities?: Entity[]; + knex?: Knex; }) { return startTestBackend({ features: [ @@ -79,6 +82,9 @@ function startScorecardBackend(options?: { defaultCredentials: mockCredentials.user('user:default/test'), }), catalogServiceMock.factory({ entities: options?.entities ?? [] }), + ...(options?.knex + ? [mockServices.database.factory({ knex: options.knex })] + : []), ], }); } @@ -102,6 +108,16 @@ const TEST_ENTITIES: Entity[] = [ spec: { type: 'service', owner: 'user:default/test' }, relations: [{ type: 'ownedBy', targetRef: 'user:default/test' }], }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-service2', + namespace: 'default', + }, + spec: { type: 'service', owner: 'user:default/test' }, + relations: [{ type: 'ownedBy', targetRef: 'user:default/test' }], + }, ]; describe('scorecard plugin (startTestBackend)', () => { @@ -296,6 +312,36 @@ describe('scorecard plugin (startTestBackend)', () => { expect(res.status).toBe(404); }); }); + + describe('GET /api/scorecard/aggregations/:aggregationId/time-series', () => { + it('returns 400 for statusGrouped default (metric id without KPI)', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/github.openPRs/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ); + + expect(res.status).toBe(400); + expect(res.body.error.name).toBe('InputError'); + expect(res.body.error.message).toMatch(/does not support time-series/); + }); + + it('returns 401 when request has no user credentials', async () => { + const res = await request(server) + .get( + '/api/scorecard/aggregations/github.openPRs/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ) + .set('Authorization', mockCredentials.none.header()); + + expect(res.status).toBe(401); + }); + + it('returns 404 for non-existent aggregation', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/non.existent/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ); + + expect(res.status).toBe(404); + }); + }); }); describe('scorecard plugin with aggregationKPIs config', () => { @@ -323,7 +369,10 @@ describe('scorecard plugin with aggregationKPIs config', () => { }; beforeAll(async () => { - ({ server } = await startScorecardBackend({ config: KPI_CONFIG })); + ({ server } = await startScorecardBackend({ + config: KPI_CONFIG, + entities: TEST_ENTITIES, + })); }); afterAll(() => { @@ -353,4 +402,152 @@ describe('scorecard plugin with aggregationKPIs config', () => { expect(res.status).toBe(200); expect(res.body.title).toBe('GitHub Open PRs'); }); + + it('returns 400 for time-series of weightedStatusScore KPIs', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/myCustomKpi/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ); + + expect(res.status).toBe(400); + expect(res.body.error.name).toBe('InputError'); + }); +}); + +describe('scorecard plugin with scalar aggregationKPI', () => { + let server: Server; + let knex: Knex; + + const KPI_CONFIG = { + ...BASE_CONFIG, + scorecard: { + aggregationKPIs: { + totalOpenPrs: { + title: 'Total Open PRs', + description: 'Sum of open PRs', + type: 'sum', + metricId: 'github.openPRs', + }, + }, + }, + }; + + beforeAll(async () => { + knex = createKnex({ + client: 'better-sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + + ({ server } = await startScorecardBackend({ + config: KPI_CONFIG, + entities: TEST_ENTITIES, + knex, + })); + + await knex('metric_values').insert( + [ + { + catalogEntityRef: stringifyEntityRef(TEST_ENTITIES[1]), + metricId: 'github.openPRs', + value: 5, + timestamp: new Date('2024-01-01T12:00:00.000Z'), + status: 'success', + }, + { + catalogEntityRef: stringifyEntityRef(TEST_ENTITIES[1]), + metricId: 'github.openPRs', + value: 12, + timestamp: new Date('2024-01-02T12:00:00.000Z'), + status: 'success', + }, + { + catalogEntityRef: stringifyEntityRef(TEST_ENTITIES[2]), + metricId: 'github.openPRs', + value: 4, + timestamp: new Date('2024-01-02T12:10:00.000Z'), + status: 'success', + }, + ].map(toMetricValueRow), + ); + }); + + afterAll(async () => { + server.close(); + await knex.destroy(); + }); + + it('returns empty scalar time-series when there is no metric data in range', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/totalOpenPrs/time-series?from=2023-01-01T00:00:00.000Z&to=2023-01-31T00:00:00.000Z', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + id: 'totalOpenPrs', + metricId: 'github.openPRs', + points: [], + aggregationChartDisplayColor: null, + metadata: expect.objectContaining({ + aggregationType: 'sum', + }), + }), + ); + expect(res.body.thresholds.rules).toEqual(expect.any(Array)); + }); + + it('returns scalar time-series points for owned entities', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/totalOpenPrs/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + id: 'totalOpenPrs', + metricId: 'github.openPRs', + metadata: expect.objectContaining({ + aggregationType: 'sum', + title: 'Total Open PRs', + }), + points: [ + { + value: 5, + successCount: 1, + errorCount: 0, + total: 1, + status: 'success', + timestamp: '2024-01-01T12:00:00.000Z', + }, + { + value: 16, + successCount: 2, + errorCount: 0, + total: 2, + status: 'success', + timestamp: '2024-01-02T12:10:00.000Z', + }, + ], + aggregationChartDisplayColor: 'warning.main', + }), + ); + }); + + it('returns 401 when request has no user credentials', async () => { + const res = await request(server) + .get( + '/api/scorecard/aggregations/totalOpenPrs/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ) + .set('Authorization', mockCredentials.none.header()); + + expect(res.status).toBe(401); + }); + + it('returns 404 for non-existent aggregation', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/non.existent/time-series?from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z', + ); + + expect(res.status).toBe(404); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts index 26c8e93b71a..fd3f6675f7a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.test.ts @@ -168,4 +168,93 @@ describe('AggregatedMetricLoader', () => { ); }); }); + + describe('loadScalarMetricTimeSeriesByEntityRefs', () => { + const dbRows = [ + { + maxTimestamp: new Date('2024-01-01T18:30:00Z'), + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + errors: [], + }, + { + maxTimestamp: new Date('2024-01-02T09:15:00Z'), + value: 5, + successCount: 3, + errorCount: 0, + total: 3, + errors: [], + }, + ]; + const readScalarAggregatedMetricTimeSeriesByEntityRefs = jest + .fn() + .mockResolvedValue(dbRows); + const from = new Date('2024-01-01T00:00:00Z'); + const to = new Date('2024-01-31T00:00:00Z'); + + let loader: AggregatedMetricLoader; + + beforeEach(() => { + loader = new AggregatedMetricLoader({ + readScalarAggregatedMetricTimeSeriesByEntityRefs, + } as unknown as DatabaseMetricValues); + }); + + it('should return no points when entityRefs is empty', async () => { + const result = await loader.loadScalarMetricTimeSeriesByEntityRefs( + [], + 'metric.id', + 'sum', + from, + to, + ); + + expect(result).toEqual([]); + expect( + readScalarAggregatedMetricTimeSeriesByEntityRefs, + ).not.toHaveBeenCalled(); + }); + + it('should map db rows to time-series points', async () => { + const result = await loader.loadScalarMetricTimeSeriesByEntityRefs( + ['component:default/a'], + 'metric.id', + 'sum', + from, + to, + { status: 'error' }, + ); + + expect( + readScalarAggregatedMetricTimeSeriesByEntityRefs, + ).toHaveBeenCalledWith( + ['component:default/a'], + 'metric.id', + 'sum', + from, + to, + { status: 'error' }, + ); + expect(result).toEqual([ + { + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + status: 'success', + timestamp: '2024-01-01T18:30:00.000Z', + }, + { + value: 5, + successCount: 3, + errorCount: 0, + total: 3, + status: 'success', + timestamp: '2024-01-02T09:15:00.000Z', + }, + ]); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts index 19b9e475298..4917007ead5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregatedMetricLoader.ts @@ -18,6 +18,7 @@ import type { AggregatedMetric, AggregationConfigFilter, ScalarAggregatedMetric, + ScalarAggregatedTimeSeriesPoint, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { DatabaseMetricValues } from '../../database/DatabaseMetricValues'; import type { ScalarAggregationFn } from '../../database/types'; @@ -63,4 +64,31 @@ export class AggregatedMetricLoader { return AggregatedMetricMapper.toScalarAggregatedMetric(scalarMetric); } + + async loadScalarMetricTimeSeriesByEntityRefs( + entityRefs: string[], + metricId: string, + aggregationFn: ScalarAggregationFn, + from: Date, + to: Date, + filter?: AggregationConfigFilter, + ): Promise { + if (entityRefs.length === 0) { + return []; + } + + const rows = + await this.database.readScalarAggregatedMetricTimeSeriesByEntityRefs( + entityRefs, + metricId, + aggregationFn, + from, + to, + filter, + ); + + return rows.map(row => + AggregatedMetricMapper.toScalarAggregatedTimeSeriesPoint(row), + ); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts index 3272f22699d..92cf51bc879 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.test.ts @@ -15,6 +15,7 @@ */ import { mockServices } from '@backstage/backend-test-utils'; +import { InputError } from '@backstage/errors'; import { aggregationTypes } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { AggregationsService } from './AggregationsService'; import type { DatabaseMetricValues } from '../../database/DatabaseMetricValues'; @@ -111,8 +112,10 @@ describe('AggregationsService', () => { aggregate: jest.fn(), } as unknown as jest.Mocked; + const scalarAggregateTimeSeries = jest.fn(); const scalarStrategy = { aggregate: jest.fn(), + aggregateTimeSeries: scalarAggregateTimeSeries, } as unknown as jest.Mocked; let service: AggregationsService; @@ -220,8 +223,33 @@ describe('AggregationsService', () => { expect(cfg.id).toBe('github.openPRs'); expect(cfg.metricId).toBe('github.openPRs'); expect(cfg.type).toBe(aggregationTypes.statusGrouped); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('github.openPRs'), + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining( + 'No "scorecard.aggregationKPIs.github.openPRs" block in app-config; using default type "statusGrouped"', + ), + ); + }); + + it('should default to average when KPI config is absent and metric is sparkline', () => { + const sparklineMetric = mockGithubOpenPrsMetric({ + id: 'dora.deploymentFrequency', + defaultVisualization: 'sparkline', + }); + const providersSparklineRegistry = buildMockMetricProvidersRegistry({ + metricsList: [sparklineMetric], + }); + + const cfg = service.getAggregationConfig( + 'dora.deploymentFrequency', + providersSparklineRegistry, + ); + + expect(cfg.metricId).toBe('dora.deploymentFrequency'); + expect(cfg.type).toBe(aggregationTypes.average); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining( + 'No "scorecard.aggregationKPIs.dora.deploymentFrequency" block in app-config; using default type "average"', + ), ); }); @@ -398,8 +426,84 @@ describe('AggregationsService', () => { ); expect(second).toBe(first); - expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledTimes(1); expect(metricProvidersRegistry.getMetric).toHaveBeenCalledTimes(1); }); }); + + describe('getAggregatedMetricTimeSeries', () => { + const from = new Date('2024-01-01T00:00:00Z'); + const to = new Date('2024-01-31T00:00:00Z'); + const scalarSeriesResult = { + id: 'totalOpenPrs', + metricId: metric.id, + points: [{ value: 12, total: 3, timestamp: '2024-01-01T00:00:00.000Z' }], + metadata: scalarApiResult.metadata, + thresholds: { rules: [] }, + aggregationChartDisplayColor: 'warning.main', + }; + + beforeEach(() => { + scalarAggregateTimeSeries.mockResolvedValue(scalarSeriesResult); + }); + + it('should call scalar strategy with time-series options', async () => { + const options = { + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig: scalarAggregationConfig, + from, + to, + }; + + const result = await service.getAggregatedMetricTimeSeries(options); + + expect(scalarAggregateTimeSeries).toHaveBeenCalledWith(options); + expect(result).toEqual(scalarSeriesResult); + }); + + it('should throw InputError for statusGrouped', async () => { + await expect( + service.getAggregatedMetricTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig: statusGroupedAggregationConfig, + from, + to, + }), + ).rejects.toBeInstanceOf(InputError); + }); + + it('should throw InputError for weightedStatusScore', async () => { + await expect( + service.getAggregatedMetricTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig: weightedAggregationConfig, + from, + to, + }), + ).rejects.toThrow(/does not support time-series/); + }); + + it('should throw when aggregation type is not registered', async () => { + await expect(() => + service.getAggregatedMetricTimeSeries({ + metric, + entityRefs: [], + thresholds: mockHigherIsBetterThresholds, + aggregationConfig: { + id: metric.id, + metricId: metric.id, + type: 'unknownStrategy' as any, + } as any, + from, + to, + }), + ).rejects.toThrow(/Unsupported aggregation type: unknownStrategy/); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.ts index 06c16d36bd6..35507f7f89f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/AggregationsService.ts @@ -14,12 +14,15 @@ * limitations under the License. */ -import type { AggregationOptions } from './types'; +import { InputError } from '@backstage/errors'; +import type { AggregationOptions, AggregationTimeSeriesOptions } from './types'; import { parseValidatedAggregationConfig } from '../../utils/aggregation/parseValidatedAggregationConfig'; import { type AggregatedMetricResult, + type AggregatedMetricTimeSeriesResponse, type AggregationType, aggregationTypes, + scalarAggregationTypes, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import type { Config } from '@backstage/config'; import { AGGREGATION_KPIS_CONFIG_PATH } from '../../constants'; @@ -40,7 +43,6 @@ export type AggregationsServiceOptions = { export class AggregationsService { private readonly config: Config; - private readonly database: DatabaseMetricValues; private readonly strategyRegistry: Map; private readonly logger: LoggerService; private readonly aggregationKpisConfigCache: Map< @@ -51,10 +53,9 @@ export class AggregationsService { constructor(options: AggregationsServiceOptions) { this.config = options.config; this.logger = options.logger; - this.database = options.database; this.aggregationKpisConfigCache = new Map(); this.strategyRegistry = createAggregationStrategyRegistry( - new AggregatedMetricLoader(this.database), + new AggregatedMetricLoader(options.database), this.logger, ); } @@ -74,20 +75,24 @@ export class AggregationsService { ); if (!config) { - this.logger.warn( + const metric = metricProviderRegistry.getMetric(aggregationId); + const defaultType = + metric.defaultVisualization === 'sparkline' + ? aggregationTypes.average + : aggregationTypes.statusGrouped; + + this.logger.info( `No "${AGGREGATION_KPIS_CONFIG_PATH}.${aggregationId}" block in app-config; ` + - `using default type "${aggregationTypes.statusGrouped}" with metricId="${aggregationId}" ` + + `using default type "${defaultType}" with metricId="${aggregationId}" ` + '(same as aggregation id). Add a KPI entry if you meant a custom title, description, or type.', ); - const metric = metricProviderRegistry.getMetric(aggregationId); - const fallbackConfig: ValidatedAggregationConfig = { id: aggregationId, metricId: aggregationId, title: metric.title, description: metric.description, - type: aggregationTypes.statusGrouped, + type: defaultType, }; this.aggregationKpisConfigCache.set(aggregationId, fallbackConfig); @@ -107,16 +112,34 @@ export class AggregationsService { async getAggregatedMetricByEntityRefs( options: AggregationOptions, ): Promise { - const { aggregationConfig } = options; + return this.getStrategy(options.aggregationConfig.type).aggregate(options); + } - const strategy = this.strategyRegistry.get(aggregationConfig.type); + async getAggregatedMetricTimeSeries( + options: AggregationTimeSeriesOptions, + ): Promise { + const strategy = this.getStrategy(options.aggregationConfig.type); + + if (!strategy.aggregateTimeSeries) { + throw new InputError( + `Aggregation type "${ + options.aggregationConfig.type + }" does not support time-series. Currently only scalar types (${scalarAggregationTypes.join( + ', ', + )}) are supported.`, + ); + } + + return strategy.aggregateTimeSeries(options); + } + + private getStrategy(type: AggregationType): AggregationStrategy { + const strategy = this.strategyRegistry.get(type); if (!strategy) { - throw new Error( - `Unsupported aggregation type: ${aggregationConfig.type}`, - ); + throw new Error(`Unsupported aggregation type: ${type}`); } - return strategy.aggregate(options); + return strategy; } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ScalarAggregationStrategy.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ScalarAggregationStrategy.ts index 598d12b1f4d..cf02b76297e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ScalarAggregationStrategy.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/ScalarAggregationStrategy.ts @@ -17,19 +17,26 @@ import { DEFAULT_NUMBER_THRESHOLDS, type AggregatedMetricResult, + type AggregatedMetricTimeSeriesResponse, type ScalarAggregationResult, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import type { ScalarAggregationFn } from '../../../database/types'; import { AggregatedMetricMapper } from '../../mappers'; import type { AggregatedMetricLoader } from '../AggregatedMetricLoader'; -import type { AggregationOptions } from '../types'; +import type { + AggregationOptions, + AggregationTimeSeriesOptions, +} from '../types'; import type { AggregationStrategy } from './types'; import { isScalarAggregationConfig } from '../../../utils/aggregation/isScalarAggregationConfig'; +import { classifyNumberAgainstThresholds } from '../../../utils/aggregation/classifyNumberAgainstThresholds'; +import { ThresholdEvaluator } from '../../../threshold/ThresholdEvaluator'; export class ScalarAggregationStrategy implements AggregationStrategy { constructor( private readonly loader: AggregatedMetricLoader, private readonly aggregationFn: ScalarAggregationFn, + private readonly thresholdEvaluator: ThresholdEvaluator = new ThresholdEvaluator(), ) {} async aggregate( @@ -74,4 +81,48 @@ export class ScalarAggregationStrategy implements AggregationStrategy { aggregationConfig, ); } + + async aggregateTimeSeries( + options: AggregationTimeSeriesOptions, + ): Promise { + const { entityRefs, metric, aggregationConfig, from, to } = options; + + if (!isScalarAggregationConfig(aggregationConfig)) { + throw new Error( + `Expected a scalar aggregation config but received type "${aggregationConfig.type}"`, + ); + } + + const headlineThresholds = + aggregationConfig.options?.thresholds ?? DEFAULT_NUMBER_THRESHOLDS; + + const points = await this.loader.loadScalarMetricTimeSeriesByEntityRefs( + entityRefs, + metric.id, + this.aggregationFn, + from, + to, + aggregationConfig.filter, + ); + + const lastSuccessValue = [...points] + .reverse() + .find(point => point.status === 'success' && point.value !== null)?.value; + const aggregationChartDisplayColor = + lastSuccessValue === undefined || lastSuccessValue === null + ? null + : classifyNumberAgainstThresholds( + lastSuccessValue, + headlineThresholds, + this.thresholdEvaluator, + )?.color ?? null; + + return AggregatedMetricMapper.toScalarAggregatedMetricTimeSeriesResponse( + metric, + aggregationConfig, + points, + headlineThresholds, + aggregationChartDisplayColor, + ); + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/scalarAggregationStrategy.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/scalarAggregationStrategy.test.ts index a5fdd1f765c..fece891807a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/scalarAggregationStrategy.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/scalarAggregationStrategy.test.ts @@ -24,7 +24,10 @@ import { ScalarAggregationStrategy } from './ScalarAggregationStrategy'; import * as aggregationUtils from '../../../utils/aggregation/isScalarAggregationConfig'; import { AggregatedMetricMapper } from '../../mappers'; import { mockScalarAggregationResult } from '../../../../__fixtures__/mockAggregatedMetricResult'; -import { mockHigherIsBetterThresholds } from '../../../../__fixtures__/mockThresholds'; +import { + mockHigherIsBetterThresholds, + mockLowerIsBetterThresholds, +} from '../../../../__fixtures__/mockThresholds'; import { mockGithubOpenPrsMetric } from '../../../../__fixtures__/mockMetric'; jest.mock('../../../utils/aggregation/isScalarAggregationConfig'); @@ -54,6 +57,7 @@ describe('ScalarAggregationStrategy', () => { loadScalarMetricByEntityRefs: jest .fn() .mockResolvedValue(loadedScalarMetric), + loadScalarMetricTimeSeriesByEntityRefs: jest.fn(), } as unknown as AggregatedMetricLoader; const strategy = new ScalarAggregationStrategy(loader, 'sum'); @@ -106,7 +110,7 @@ describe('ScalarAggregationStrategy', () => { ); }); - it('should use default thresholds when no provided', async () => { + it('should use default thresholds when custom not provided', async () => { const defaultAggregationConfig = mockScalarAggregationConfig( aggregationTypes.sum, { @@ -130,6 +134,21 @@ describe('ScalarAggregationStrategy', () => { ); }); + it('should use KPI options.thresholds when provided', async () => { + await strategy.aggregate({ + metric, + entityRefs, + thresholds: mockLowerIsBetterThresholds, // thresholds from metric are not used + aggregationConfig, + }); + + expect(spyMethods.toAggregatedMetricResultSpy).toHaveBeenCalledWith( + metric, + { ...loadedScalarMetric, thresholds: mockHigherIsBetterThresholds }, + aggregationConfig, + ); + }); + it('should load scalar aggregate and maps to API result', async () => { await strategy.aggregate({ metric, @@ -206,4 +225,176 @@ describe('ScalarAggregationStrategy', () => { result: mockScalarAggregationResult, }); }); + + describe('aggregateTimeSeries', () => { + const from = new Date('2024-01-01T00:00:00Z'); + const to = new Date('2024-01-31T00:00:00Z'); + const loadedPoints = [ + { + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + status: 'success' as const, + timestamp: '2024-01-01T00:00:00.000Z', + }, + ]; + + beforeEach(() => { + ( + loader.loadScalarMetricTimeSeriesByEntityRefs as jest.Mock + ).mockResolvedValue(loadedPoints); + }); + + it('should throw when aggregationFn is not a scalar type', async () => { + spyMethods.isScalarAggregationConfigSpy.mockReturnValue(false); + + await expect(() => + strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig, + from, + to, + }), + ).rejects.toThrow(/Expected a scalar aggregation config/); + }); + + it('should use default thresholds when custom not provided', async () => { + const defaultAggregationConfig = mockScalarAggregationConfig( + aggregationTypes.sum, + { + id: 'totalOpenPrs', + metricId: metric.id, + options: {}, + }, + ); + + const result = await strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockLowerIsBetterThresholds, + aggregationConfig: defaultAggregationConfig, + from, + to, + }); + + expect(result.thresholds).toEqual(DEFAULT_NUMBER_THRESHOLDS); + expect(result.aggregationChartDisplayColor).toBe('warning.main'); + }); + + it('should use KPI options.thresholds when provided', async () => { + const result = await strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockLowerIsBetterThresholds, + aggregationConfig, + from, + to, + }); + + expect(result.thresholds).toEqual(mockHigherIsBetterThresholds); + expect(result.thresholds).not.toEqual(mockLowerIsBetterThresholds); + expect(result.aggregationChartDisplayColor).toBe('red'); + }); + + it('should load scalar time series and classify the latest point', async () => { + const result = await strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig, + from, + to, + }); + + expect( + loader.loadScalarMetricTimeSeriesByEntityRefs, + ).toHaveBeenCalledWith(entityRefs, metric.id, 'sum', from, to, undefined); + expect(result).toEqual({ + id: aggregationConfig.id, + metricId: metric.id, + points: loadedPoints, + metadata: expect.objectContaining({ + aggregationType: aggregationTypes.sum, + }), + thresholds: mockHigherIsBetterThresholds, + aggregationChartDisplayColor: 'red', + }); + }); + + it('should classify the last successful point when a later day is only errors', async () => { + ( + loader.loadScalarMetricTimeSeriesByEntityRefs as jest.Mock + ).mockResolvedValue([ + ...loadedPoints, + { + value: null, + successCount: 0, + errorCount: 1, + total: 1, + status: 'error' as const, + errors: [{ message: 'boom', count: 1 }], + timestamp: '2024-01-02T00:00:00.000Z', + }, + ]); + + const result = await strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig, + from, + to, + }); + + expect(result.aggregationChartDisplayColor).toBe('red'); + }); + + it('should set aggregationChartDisplayColor to null when there are no points', async () => { + ( + loader.loadScalarMetricTimeSeriesByEntityRefs as jest.Mock + ).mockResolvedValue([]); + + const result = await strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig, + from, + to, + }); + + expect(result.points).toEqual([]); + expect(result.aggregationChartDisplayColor).toBeNull(); + expect(result.thresholds).toEqual(mockHigherIsBetterThresholds); + }); + + it('should forward filter.status to the scalar time-series loader', async () => { + const filteredConfig = mockScalarAggregationConfig(aggregationTypes.sum, { + id: 'totalCriticalPrs', + metricId: metric.id, + filter: { status: 'error' }, + options: { + thresholds: mockHigherIsBetterThresholds, + }, + }); + + await strategy.aggregateTimeSeries({ + metric, + entityRefs, + thresholds: mockHigherIsBetterThresholds, + aggregationConfig: filteredConfig, + from, + to, + }); + + expect( + loader.loadScalarMetricTimeSeriesByEntityRefs, + ).toHaveBeenCalledWith(entityRefs, metric.id, 'sum', from, to, { + status: 'error', + }); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/types.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/types.ts index 5f362238e7c..3ffe6549927 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/strategies/types.ts @@ -14,9 +14,21 @@ * limitations under the License. */ -import type { AggregatedMetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import type { AggregationOptions } from '../types'; +import type { + AggregatedMetricResult, + AggregatedMetricTimeSeriesResponse, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { + AggregationOptions, + AggregationTimeSeriesOptions, +} from '../types'; export interface AggregationStrategy { aggregate(options: AggregationOptions): Promise; + /** + * Daily portfolio aggregation. + */ + aggregateTimeSeries?( + options: AggregationTimeSeriesOptions, + ): Promise; } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts index 5375f59f781..082feabe092 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/aggregations/types.ts @@ -26,3 +26,8 @@ export type AggregationOptions = { thresholds: ThresholdConfig; aggregationConfig: ValidatedAggregationConfig; }; + +export type AggregationTimeSeriesOptions = AggregationOptions & { + from: Date; + to: Date; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts index f695105184a..25d93d4a56f 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.test.ts @@ -154,6 +154,7 @@ describe('AggregatedMetricMapper', () => { type: 'number', unit: undefined, history: undefined, + visualization: undefined, aggregationType: aggregationTypes.statusGrouped, }); }); @@ -175,6 +176,7 @@ describe('AggregatedMetricMapper', () => { type: 'number', unit: undefined, history: undefined, + visualization: undefined, aggregationType: aggregationTypes.weightedStatusScore, }); }); @@ -206,11 +208,12 @@ describe('AggregatedMetricMapper', () => { expect(result).not.toHaveProperty('filter'); }); - it('should include unit and history when defined on the metric', () => { + it('should include unit, history and visualization when defined on the metric', () => { const metricWithUnitAndHistory: Metric = { ...mockMetric, unit: 'h', history: true, + defaultVisualization: 'sparkline', }; const aggregationConfig = mockStatusGroupedAggregationConfig({ @@ -229,6 +232,7 @@ describe('AggregatedMetricMapper', () => { type: 'number', unit: 'h', history: true, + visualization: 'sparkline', aggregationType: aggregationTypes.statusGrouped, }); }); @@ -236,6 +240,14 @@ describe('AggregatedMetricMapper', () => { describe('toAggregatedMetricResult', () => { const thresholds: ThresholdConfig = DEFAULT_NUMBER_THRESHOLDS; + const toAggregationMetadataSpy = jest.spyOn( + AggregatedMetricMapper, + 'toAggregationMetadata', + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); it('should wrap a statusGrouped-shaped result and aggregation metadata from config', () => { const aggregationConfig = mockStatusGroupedAggregationConfig({ @@ -260,6 +272,7 @@ describe('AggregatedMetricMapper', () => { aggregationConfig, ); + expect(toAggregationMetadataSpy).toHaveBeenCalledTimes(1); expect(result).toEqual({ id: 'test.metric', status: 'success', @@ -269,6 +282,7 @@ describe('AggregatedMetricMapper', () => { type: 'number', unit: undefined, history: undefined, + visualization: undefined, aggregationType: 'statusGrouped', }, result: { @@ -311,6 +325,7 @@ describe('AggregatedMetricMapper', () => { aggregationConfig, ); + expect(toAggregationMetadataSpy).toHaveBeenCalledTimes(1); expect(result.metadata.aggregationType).toBe( aggregationTypes.weightedStatusScore, ); @@ -337,6 +352,7 @@ describe('AggregatedMetricMapper', () => { aggregationConfig, ); + expect(toAggregationMetadataSpy).toHaveBeenCalledTimes(1); expect(result.metadata.filter).toEqual({ status: 'error' }); }); @@ -357,7 +373,151 @@ describe('AggregatedMetricMapper', () => { aggregationConfig, ); + expect(toAggregationMetadataSpy).toHaveBeenCalledTimes(1); expect(result.metadata).not.toHaveProperty('filter'); }); }); + + describe('toScalarAggregatedTimeSeriesPoint', () => { + it('should omit errors when none are present', () => { + expect( + AggregatedMetricMapper.toScalarAggregatedTimeSeriesPoint({ + maxTimestamp: new Date('2024-01-01T18:30:00Z'), + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + errors: [], + }), + ).toEqual({ + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + status: 'success', + timestamp: '2024-01-01T18:30:00.000Z', + }); + }); + + it('should map a successful day', () => { + expect( + AggregatedMetricMapper.toScalarAggregatedTimeSeriesPoint({ + maxTimestamp: new Date('2024-01-01T18:30:00Z'), + value: 12, + successCount: 3, + errorCount: 1, + total: 4, + errors: [{ message: 'boom', count: 1 }], + }), + ).toEqual({ + value: 12, + successCount: 3, + errorCount: 1, + total: 4, + status: 'success', + errors: [{ message: 'boom', count: 1 }], + timestamp: '2024-01-01T18:30:00.000Z', + }); + }); + + it('should map an error-only day with null value and status error', () => { + expect( + AggregatedMetricMapper.toScalarAggregatedTimeSeriesPoint({ + maxTimestamp: new Date('2024-01-01T18:30:00Z'), + value: null, + successCount: 0, + errorCount: 2, + total: 2, + errors: [{ message: 'boom', count: 2 }], + }), + ).toEqual({ + value: null, + successCount: 0, + errorCount: 2, + total: 2, + status: 'error', + errors: [{ message: 'boom', count: 2 }], + timestamp: '2024-01-01T18:30:00.000Z', + }); + }); + }); + + describe('toScalarAggregatedMetricTimeSeriesResponse', () => { + const toAggregationMetadataSpy = jest.spyOn( + AggregatedMetricMapper, + 'toAggregationMetadata', + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should wrap points with aggregation id, thresholds, and aggregationChartDisplayColor', () => { + const aggregationConfig = mockScalarAggregationConfig( + aggregationTypes.sum, + { + id: 'totalOpenPrs', + }, + ); + const points = [ + { + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + status: 'success' as const, + timestamp: '2024-01-01T00:00:00.000Z', + }, + ]; + + const result = + AggregatedMetricMapper.toScalarAggregatedMetricTimeSeriesResponse( + mockMetric, + aggregationConfig, + points, + DEFAULT_NUMBER_THRESHOLDS, + 'warning.main', + ); + + expect(result).toEqual({ + id: 'totalOpenPrs', + metricId: 'test.metric', + points, + metadata: expect.objectContaining({ + aggregationType: aggregationTypes.sum, + }), + thresholds: DEFAULT_NUMBER_THRESHOLDS, + aggregationChartDisplayColor: 'warning.main', + }); + }); + + it('should wrap points with metadata using `toAggregatedMetadata`', () => { + const aggregationConfig = mockScalarAggregationConfig( + aggregationTypes.sum, + { + id: 'totalOpenPrs', + }, + ); + + const result = + AggregatedMetricMapper.toScalarAggregatedMetricTimeSeriesResponse( + mockMetric, + aggregationConfig, + [], + DEFAULT_NUMBER_THRESHOLDS, + null, + ); + + expect(toAggregationMetadataSpy).toHaveBeenCalledTimes(1); + expect(result.metadata).toEqual({ + description: 'Scalar aggregation KPI', + history: undefined, + title: 'Scalar KPI', + type: 'number', + unit: undefined, + visualization: undefined, + aggregationType: aggregationTypes.sum, + }); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts index 116207d538e..cf94b4b76eb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/mappers.ts @@ -17,13 +17,19 @@ import { AggregatedMetric, AggregatedMetricResult, + AggregatedMetricTimeSeriesResponse, AggregationMetadata, Metric, AggregationResultByType, ScalarAggregatedMetric, + ScalarAggregatedTimeSeriesPoint, + ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { DbAggregatedMetric } from '../database/types'; -import type { DbScalarAggregatedMetric } from '../database/types'; +import type { + DbScalarAggregatedMetric, + DbScalarTimeSeriesPoint, +} from '../database/types'; import { ValidatedAggregationConfig } from '../validation/schemas/aggregationConfigSchemas'; import { normalizeTimestamp } from '../utils/normalizeTimestamp'; @@ -69,6 +75,7 @@ export class AggregatedMetricMapper { type: metric.type, unit: metric.unit, history: metric.history, + visualization: metric.defaultVisualization, title: aggregationConfig.title, description: aggregationConfig.description, aggregationType: aggregationConfig.type, @@ -93,4 +100,40 @@ export class AggregatedMetricMapper { result, }; } + + static toScalarAggregatedTimeSeriesPoint( + row: DbScalarTimeSeriesPoint, + ): ScalarAggregatedTimeSeriesPoint { + const successCount = row.successCount; + const errorCount = row.errorCount; + const status: ScalarAggregatedTimeSeriesPoint['status'] = + successCount > 0 ? 'success' : 'error'; + + return { + value: successCount > 0 ? row.value : null, + successCount, + errorCount, + total: row.total, + status, + timestamp: row.maxTimestamp.toISOString(), + ...(row.errors.length > 0 ? { errors: row.errors } : {}), + }; + } + + static toScalarAggregatedMetricTimeSeriesResponse( + metric: Metric, + aggregationConfig: ValidatedAggregationConfig, + points: ScalarAggregatedTimeSeriesPoint[], + thresholds: ThresholdConfig, + aggregationChartDisplayColor: string | null, + ): AggregatedMetricTimeSeriesResponse { + return { + id: aggregationConfig.id, + metricId: metric.id, + points, + metadata: this.toAggregationMetadata(metric, aggregationConfig), + thresholds, + aggregationChartDisplayColor, + }; + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index 4a1bb5c2192..cae23c10960 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -35,6 +35,7 @@ import { AggregatedMetricResult, aggregationTypes, DEFAULT_NUMBER_THRESHOLDS, + scalarAggregationTypes, Metric, MetricResult, MetricTimeSeriesResponse, @@ -105,6 +106,7 @@ const CONDITIONAL_POLICY_DECISION: PolicyDecision = { describe('createRouter', () => { let app: express.Express; + let catalog: ReturnType; let metricProvidersRegistry: MetricProvidersRegistry; let catalogMetricService: CatalogMetricService; let aggregationsService: AggregationsService; @@ -126,7 +128,7 @@ describe('createRouter', () => { mockServices.rootConfig({ data: {} }), metricProvidersRegistry.listProviders(), ); - const catalog = catalogServiceMock.mock(); + catalog = catalogServiceMock.mock(); mockLogger = mockServices.logger.mock(); collectorsService = { init: jest.fn(), @@ -649,7 +651,7 @@ describe('createRouter', () => { type: 'number', unit: undefined, history: true, - defaultVisualization: 'value', + defaultVisualization: 'donut', }, points: [ { value: 8, timestamp: '2024-01-01T20:00:00.000Z' }, @@ -1711,6 +1713,266 @@ describe('createRouter', () => { }); }); + describe('GET /aggregations/:aggregationId/time-series', () => { + const validQuery = + 'from=2024-01-01T00:00:00.000Z&to=2024-01-31T00:00:00.000Z'; + const timeSeriesPath = (aggregationId: string) => + `/aggregations/${aggregationId}/time-series`; + + beforeEach(() => { + metricProvidersRegistry.register( + new MockNumberProvider('github.openPRs', 'github', 'GitHub Open PRs'), + ); + jest + .spyOn(getEntitiesOwnedByUserModule, 'getEntitiesOwnedByUser') + .mockResolvedValue([ + 'component:default/my-service', + 'component:default/my-other-service', + ]); + }); + + it('should return 400 InputError when query parameters are missing', async () => { + const response = await request(app).get(timeSeriesPath('github.openPRs')); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + expect(response.body.error.message).toContain('Invalid query parameters'); + }); + + it('should return 400 InputError when from or to is not ISO datetime', async () => { + const response = await request(app).get( + `${timeSeriesPath('github.openPRs')}?from=2024-01-01&to=2024-01-31`, + ); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + expect(response.body.error.message).toContain('Invalid query parameters'); + }); + + it('should return 400 InputError when from is after to', async () => { + const response = await request(app).get( + `${timeSeriesPath( + 'github.openPRs', + )}?from=2024-02-01T00:00:00.000Z&to=2024-01-01T00:00:00.000Z`, + ); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + expect(response.body.error.message).toContain( + 'from must be less than or equal to to', + ); + }); + + it('should return 400 InputError when range exceeds 365 days', async () => { + const response = await request(app).get( + `${timeSeriesPath( + 'github.openPRs', + )}?from=2024-01-01T00:00:00.000Z&to=2025-01-01T00:00:00.001Z`, + ); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + expect(response.body.error.message).toContain( + 'time range must not exceed 365 days', + ); + }); + + it('should return 404 NotFoundError when aggregation id is not found', async () => { + const response = await request(app).get( + `${timeSeriesPath('non.existent.metric')}?${validQuery}`, + ); + + expect(response.status).toBe(404); + expect(response.body.error.name).toBe('NotFoundError'); + expect(response.body.error.message).toContain( + 'No metric provider registered', + ); + }); + + it('should return 403 when permissions DENY', async () => { + permissionsMock.authorizeConditional.mockResolvedValueOnce([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app).get( + `${timeSeriesPath('github.openPRs')}?${validQuery}`, + ); + + expect(response.status).toBe(403); + expect(response.body.error.name).toBe('NotAllowedError'); + }); + + it('should return 401 when user entity ref is missing', async () => { + httpAuthMock.credentials.mockResolvedValueOnce({ + principal: {}, + } as any); + + const response = await request(app).get( + `${timeSeriesPath('github.openPRs')}?${validQuery}`, + ); + + expect(response.status).toBe(401); + expect(response.body.error.name).toBe('AuthenticationError'); + }); + + it('should return 400 InputError for statusGrouped', async () => { + const response = await request(app).get( + `${timeSeriesPath('github.openPRs')}?${validQuery}`, + ); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + expect(response.body.error.message).toMatch( + /does not support time-series/, + ); + }); + + it.each([...scalarAggregationTypes])( + 'should return 200 for scalar type %s', + async aggregationType => { + jest + .spyOn( + mockDatabaseMetricValues, + 'readScalarAggregatedMetricTimeSeriesByEntityRefs', + ) + .mockResolvedValue([ + { + maxTimestamp: new Date('2024-01-01T10:30:00.000Z'), + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + errors: [], + }, + ]); + + jest + .spyOn(aggregationsService, 'getAggregationConfig') + .mockReturnValue({ + id: `${aggregationType}ScalarKpi`, + title: 'Scalar KPI', + description: 'Scalar daily aggregate', + type: aggregationType, + metricId: 'github.openPRs', + }); + + const response = await request(app).get( + `${timeSeriesPath(`${aggregationType}ScalarKpi`)}?${validQuery}`, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + id: `${aggregationType}ScalarKpi`, + metricId: 'github.openPRs', + metadata: expect.objectContaining({ + aggregationType, + }), + points: [ + { + value: 12, + successCount: 3, + errorCount: 0, + total: 3, + status: 'success', + timestamp: '2024-01-01T10:30:00.000Z', + }, + ], + thresholds: DEFAULT_NUMBER_THRESHOLDS, + aggregationChartDisplayColor: 'warning.main', + }), + ); + expect(aggregationsService.getAggregationConfig).toHaveBeenCalledWith( + `${aggregationType}ScalarKpi`, + metricProvidersRegistry, + ); + expect( + mockDatabaseMetricValues.readScalarAggregatedMetricTimeSeriesByEntityRefs, + ).toHaveBeenCalledWith( + [ + 'component:default/my-service', + 'component:default/my-other-service', + ], + 'github.openPRs', + aggregationType, + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-01-31T00:00:00.000Z'), + undefined, + ); + }, + ); + + it('should use KPI filter.status and return filtered scalar time-series result', async () => { + jest + .spyOn( + mockDatabaseMetricValues, + 'readScalarAggregatedMetricTimeSeriesByEntityRefs', + ) + .mockResolvedValue([ + { + maxTimestamp: new Date('2024-01-01T10:30:00.000Z'), + value: 10, + successCount: 2, + errorCount: 0, + total: 2, + errors: [], + }, + ]); + + jest.spyOn(aggregationsService, 'getAggregationConfig').mockReturnValue({ + id: 'averageErrorOpenPRs', + title: 'Average open PRs in error state', + description: 'Average for entities in error status within open PRs', + type: aggregationTypes.average, + metricId: 'github.openPRs', + filter: { + status: 'error', + }, + }); + + const response = await request(app).get( + `${timeSeriesPath('averageErrorOpenPRs')}?${validQuery}`, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + id: 'averageErrorOpenPRs', + metricId: 'github.openPRs', + metadata: expect.objectContaining({ + aggregationType: aggregationTypes.average, + }), + points: [ + { + value: 10, + successCount: 2, + errorCount: 0, + total: 2, + status: 'success', + timestamp: '2024-01-01T10:30:00.000Z', + }, + ], + thresholds: DEFAULT_NUMBER_THRESHOLDS, + aggregationChartDisplayColor: 'warning.main', + }), + ); + expect(aggregationsService.getAggregationConfig).toHaveBeenCalledWith( + 'averageErrorOpenPRs', + metricProvidersRegistry, + ); + expect( + mockDatabaseMetricValues.readScalarAggregatedMetricTimeSeriesByEntityRefs, + ).toHaveBeenCalledWith( + ['component:default/my-service', 'component:default/my-other-service'], + 'github.openPRs', + aggregationTypes.average, + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-01-31T00:00:00.000Z'), + { status: 'error' }, + ); + }); + }); + describe('GET /aggregations/:aggregationId/metadata', () => { let metaRegistry: MetricProvidersRegistry; let metaCatalog: ReturnType; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts index 8c23173233d..a5e53184450 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts @@ -37,7 +37,10 @@ import { } from '../permissions/permissionUtils'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { validateMetricIdsQueryParams } from '../middlewares/validateMetricIdsQueryParams'; -import { validateTimeSeriesQueryParams } from '../middlewares/validateTimeSeriesQueryParams'; +import { + validateAggregationTimeSeriesQueryParams, + validateTimeSeriesQueryParams, +} from '../middlewares/validateTimeSeriesQueryParams'; import { getEntitiesOwnedByUser } from '../utils/getEntitiesOwnedByUser'; import { parseCommaSeparatedString } from '../utils/parseCommaSeparatedString'; import { AggregatedMetricMapper } from './mappers'; @@ -406,5 +409,63 @@ export async function createRouter({ }, ); + router.get( + '/aggregations/:aggregationId/time-series', + validateAggregationIdParam, + validateAggregationTimeSeriesQueryParams, + async (req, res) => { + const { aggregationId } = req.params; + const { from, to } = req.query; + + const credentials = await httpAuth.credentials(req, { allow: ['user'] }); + + const { conditions } = await authorizeConditional( + credentials, + permissions, + scorecardMetricReadPermission, + ); + + const userEntityRef = await getUserEntityRef(credentials); + + const aggregationConfig = aggregationsService.getAggregationConfig( + aggregationId, + metricProvidersRegistry, + ); + + const metric = metricProvidersRegistry.getMetric( + aggregationConfig.metricId, + ); + + const entitiesOwnedByAUser = await getEntitiesOwnedByUser(userEntityRef, { + catalog, + credentials, + }); + + for (const entityRef of entitiesOwnedByAUser) { + await checkEntityAccess(entityRef, req, permissions, httpAuth); + } + + const authorizedMetrics = filterAuthorizedMetrics([metric], conditions); + if (authorizedMetrics.length === 0) { + throw new NotAllowedError( + `To view the aggregation of a scorecard metric, your administrator must grant you the required permission.`, + ); + } + + const thresholds = thresholdResolver.resolveMetricThresholds(metric); + + res.json( + await aggregationsService.getAggregatedMetricTimeSeries({ + metric, + thresholds, + aggregationConfig, + entityRefs: entitiesOwnedByAUser, + from: new Date(from as string), + to: new Date(to as string), + }), + ); + }, + ); + return router; } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/classifyNumberAgainstThresholds.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/classifyNumberAgainstThresholds.test.ts new file mode 100644 index 00000000000..25c6677f057 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/classifyNumberAgainstThresholds.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { ThresholdEvaluator } from '../../threshold/ThresholdEvaluator'; +import { classifyNumberAgainstThresholds } from './classifyNumberAgainstThresholds'; + +describe('classifyNumberAgainstThresholds', () => { + const evaluator = new ThresholdEvaluator(); + + it('fills default color and icon on the matching standard rule', () => { + expect( + classifyNumberAgainstThresholds(12, DEFAULT_NUMBER_THRESHOLDS, evaluator), + ).toEqual({ + key: 'warning', + expression: '10-50', + color: 'warning.main', + icon: 'scorecardWarningStatusIcon', + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/classifyNumberAgainstThresholds.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/classifyNumberAgainstThresholds.ts new file mode 100644 index 00000000000..e2663bd43eb --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/classifyNumberAgainstThresholds.ts @@ -0,0 +1,42 @@ +/* + * 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 { + ThresholdConfig, + ThresholdRule, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { ThresholdEvaluator } from '../../threshold/ThresholdEvaluator'; +import { withStandardThresholdDefaults } from './withStandardThresholdDefaults'; + +export function classifyNumberAgainstThresholds( + value: number, + thresholds: ThresholdConfig, + evaluator: ThresholdEvaluator, +): ThresholdRule | undefined { + const evaluation = evaluator.getFirstMatchingThreshold( + value, + 'number', + thresholds, + ); + if (!evaluation) { + return undefined; + } + const rule = thresholds.rules.find(r => r.key === evaluation); + if (!rule) { + return undefined; + } + return withStandardThresholdDefaults(rule); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/withStandardThresholdDefaults.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/withStandardThresholdDefaults.test.ts new file mode 100644 index 00000000000..520e2ee1f9f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/withStandardThresholdDefaults.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { ScorecardThresholdRuleColors } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { withStandardThresholdDefaults } from './withStandardThresholdDefaults'; + +describe('withStandardThresholdDefaults', () => { + it.each([ + [ + 'success', + ScorecardThresholdRuleColors.SUCCESS, + 'scorecardSuccessStatusIcon', + ], + [ + 'warning', + ScorecardThresholdRuleColors.WARNING, + 'scorecardWarningStatusIcon', + ], + ['error', ScorecardThresholdRuleColors.ERROR, 'scorecardErrorStatusIcon'], + ] as const)( + 'fills default color and icon for %s when omitted', + (key, color, icon) => { + expect(withStandardThresholdDefaults({ key, expression: '<10' })).toEqual( + { + key, + expression: '<10', + color, + icon, + }, + ); + }, + ); + + it('does not overwrite an explicit color or icon on a standard key', () => { + expect( + withStandardThresholdDefaults({ + key: 'success', + expression: '<10', + color: '#00ff00', + icon: 'CustomIcon', + }), + ).toEqual({ + key: 'success', + expression: '<10', + color: '#00ff00', + icon: 'CustomIcon', + }); + }); + + it('fills only the omitted standard default', () => { + expect( + withStandardThresholdDefaults({ + key: 'warning', + expression: '10-50', + color: '#ffaa00', + }), + ).toEqual({ + key: 'warning', + expression: '10-50', + color: '#ffaa00', + icon: 'scorecardWarningStatusIcon', + }); + }); + + it('returns a custom-key rule unchanged', () => { + const rule = { key: 'elite', expression: '>=7', color: 'success.main' }; + + expect(withStandardThresholdDefaults(rule)).toBe(rule); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/withStandardThresholdDefaults.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/withStandardThresholdDefaults.ts new file mode 100644 index 00000000000..1b34cc84364 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/aggregation/withStandardThresholdDefaults.ts @@ -0,0 +1,53 @@ +/* + * 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 { + ScorecardThresholdRuleColors, + type ThresholdRule, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +const STANDARD_THRESHOLD_DEFAULTS: Record< + string, + { color: string; icon: string } +> = { + success: { + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + warning: { + color: ScorecardThresholdRuleColors.WARNING, + icon: 'scorecardWarningStatusIcon', + }, + error: { + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, +}; + +export function withStandardThresholdDefaults( + rule: ThresholdRule, +): ThresholdRule { + const defaults = STANDARD_THRESHOLD_DEFAULTS[rule.key]; + if (!defaults) { + return rule; + } + + return { + ...rule, + color: rule.color ?? defaults.color, + icon: rule.icon ?? defaults.icon, + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index 11f72ae46b2..a655b08089a 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -23,6 +23,10 @@ export type AggregatedMetricResult = { result: AggregationResultByType; }; +// @public +export type AggregatedMetricTimeSeriesResponse = + ScalarAggregatedMetricTimeSeriesResponse; + // @public (undocumented) export type AggregatedMetricValue = { count: number; @@ -59,6 +63,7 @@ export type AggregationMetadata = { type: MetricType; unit?: string; history?: boolean; + visualization?: ScorecardVisualizationType; aggregationType: AggregationType; filter?: AggregationConfigFilter; }; @@ -146,13 +151,10 @@ export type Metric = { thresholds: ThresholdConfig; unit?: string; history?: boolean; - defaultVisualization?: MetricDefaultVisualization; + defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; -// @public -export type MetricDefaultVisualization = 'value' | 'sparkline'; - // @public (undocumented) export type MetricResult = { id: string; @@ -163,7 +165,7 @@ export type MetricResult = { type: MetricType; unit?: string; history?: boolean; - defaultVisualization?: MetricDefaultVisualization; + defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; result: { @@ -192,7 +194,7 @@ export type MetricTimeSeriesResponse = { type: MetricType; unit?: string; history?: boolean; - defaultVisualization?: MetricDefaultVisualization; + defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; }; @@ -215,6 +217,27 @@ export type ScalarAggregatedMetric = Omit & { value: number; }; +// @public +export type ScalarAggregatedMetricTimeSeriesResponse = { + id: string; + metricId: string; + metadata: AggregationMetadata; + points: ScalarAggregatedTimeSeriesPoint[]; + thresholds: ThresholdConfig; + aggregationChartDisplayColor: string | null; +}; + +// @public +export type ScalarAggregatedTimeSeriesPoint = { + value: number | null; + successCount: number; + errorCount: number; + total: number; + status: 'success' | 'error'; + errors?: TimeSeriesPointError[]; + timestamp: string; +}; + // @public (undocumented) export type ScalarAggregationResult = ScalarAggregatedMetric & { thresholds: ThresholdConfig; @@ -261,6 +284,16 @@ export const ScorecardThresholdRuleColors: { readonly ERROR: 'error.main'; }; +// @public +export type ScorecardVisualizationType = + (typeof ScorecardVisualizationTypes)[keyof typeof ScorecardVisualizationTypes]; + +// @public (undocumented) +export const ScorecardVisualizationTypes: { + readonly DONUT: 'donut'; + readonly SPARKLINE: 'sparkline'; +}; + // @public (undocumented) export type StatusGroupedAggregationResult = Omit< AggregatedMetric, @@ -294,6 +327,12 @@ export type ThresholdRule = { icon?: string; }; +// @public +export type TimeSeriesPointError = { + message: string; + count: number; +}; + // @public (undocumented) export type WeightedStatusScoreAggregationResult = StatusGroupedAggregationResult & { diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts index 7f906aa4227..2189173db92 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ScorecardVisualizationType } from './scorecard'; import { ThresholdConfig, ThresholdResult } from './threshold'; /** @@ -21,14 +22,6 @@ import { ThresholdConfig, ThresholdResult } from './threshold'; */ export type MetricType = 'number' | 'boolean'; -/** - * Default visualization for a metric on the entity scorecard. - * Omit / undefined means `'value'`. - * - * @public - */ -export type MetricDefaultVisualization = 'value' | 'sparkline'; - /** * @public */ @@ -49,7 +42,7 @@ export type Metric = { thresholds: ThresholdConfig; unit?: string; history?: boolean; - defaultVisualization?: MetricDefaultVisualization; + defaultVisualization?: ScorecardVisualizationType; /** * Collector IDs used to gather data for this metric, extracted from * provider config at startup. Omitted when the metric does not use collectors. @@ -69,7 +62,7 @@ export type MetricResult = { type: MetricType; unit?: string; history?: boolean; - defaultVisualization?: MetricDefaultVisualization; + defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; result: { @@ -161,7 +154,7 @@ export type MetricTimeSeriesResponse = { type: MetricType; unit?: string; history?: boolean; - defaultVisualization?: MetricDefaultVisualization; + defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; }; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts index fc5cb61372a..a7008994afa 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/aggregation.ts @@ -16,6 +16,7 @@ import { aggregationTypes } from '../constants/aggregations'; import { MetricType } from './Metric'; +import { ScorecardVisualizationType } from './scorecard'; import { ThresholdConfig } from './threshold'; /** @@ -78,6 +79,7 @@ export type AggregationMetadata = { type: MetricType; unit?: string; history?: boolean; + visualization?: ScorecardVisualizationType; aggregationType: AggregationType; filter?: AggregationConfigFilter; }; @@ -151,3 +153,66 @@ export type AggregationConfig = { filter?: AggregationConfigFilter; options?: AggregationConfigOptions; }; + +/** + * Unique calculation-error message for a UTC day, with how many entities reported it. + * @public + */ +export type TimeSeriesPointError = { + message: string; + count: number; +}; + +/** + * One UTC-day scalar aggregate across entities. + * @public + */ +export type ScalarAggregatedTimeSeriesPoint = { + /** Aggregate of latest successful values that day; `null` when `successCount` is 0. */ + value: number | null; + /** Entities whose latest row that day has a real value. */ + successCount: number; + /** Entities whose latest row that day is a calculation failure. */ + errorCount: number; + /** `successCount + errorCount` (entities that reported that day). */ + total: number; + /** + * `success` when `successCount > 0`, `error` when only calculation failures. + */ + status: 'success' | 'error'; + /** + * Unique error messages for that day. Omitted when there are none. + */ + errors?: TimeSeriesPointError[]; + /** Start of the UTC calendar day (ISO-8601). */ + timestamp: string; +}; + +/** + * Scalar aggregation over a specified time period, grouped by UTC day. + * The `points` array contains the aggregated values for each day where data was reported. + * @public + */ +export type ScalarAggregatedMetricTimeSeriesResponse = { + id: string; + metricId: string; + metadata: AggregationMetadata; + points: ScalarAggregatedTimeSeriesPoint[]; + /** + * KPI `options.thresholds`, or `DEFAULT_NUMBER_THRESHOLDS` when omitted. + */ + thresholds: ThresholdConfig; + /** + * Chart color from classifying the last **successful** point's `value` against + * `thresholds`. `null` when no day has a value or the matching rule has no color. + */ + aggregationChartDisplayColor: string | null; +}; + +/** + * Daily portfolio aggregation time series. + * Currently only scalar aggregation types; other members may be added to as union later. + * @public + */ +export type AggregatedMetricTimeSeriesResponse = + ScalarAggregatedMetricTimeSeriesResponse; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts index f1b6e93e10e..461caf71fe2 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/index.ts @@ -18,3 +18,4 @@ export * from './Metric'; export * from './threshold'; export * from './aggregation'; export * from './collector'; +export * from './scorecard'; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/scorecard.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/scorecard.ts new file mode 100644 index 00000000000..c301af7b38e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/scorecard.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export const ScorecardVisualizationTypes = { + DONUT: 'donut', + SPARKLINE: 'sparkline', +} as const; + +/** + * Scorecard data visualization type + * @public + */ +export type ScorecardVisualizationType = + (typeof ScorecardVisualizationTypes)[keyof typeof ScorecardVisualizationTypes];