From 1cd836449cc8165ffe756c0ca65a3dbc7adc167f Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Thu, 10 Sep 2026 14:02:13 +0200 Subject: [PATCH 1/6] feat: return threshold config, threholdEvaluation per point Signed-off-by: Diana Janickova --- .../.changeset/breezy-numbers-hide.md | 6 ++ .../plugins/scorecard-backend/README.md | 23 +++++- .../src/service/CatalogMetricService.test.ts | 82 +++++++++++++++++-- .../src/service/CatalogMetricService.ts | 12 +++ .../src/service/router.test.ts | 19 ++++- .../plugins/scorecard-common/report.api.md | 2 + .../scorecard-common/src/types/Metric.ts | 10 +++ 7 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 workspaces/scorecard/.changeset/breezy-numbers-hide.md diff --git a/workspaces/scorecard/.changeset/breezy-numbers-hide.md b/workspaces/scorecard/.changeset/breezy-numbers-hide.md new file mode 100644 index 00000000000..50e98260a1b --- /dev/null +++ b/workspaces/scorecard/.changeset/breezy-numbers-hide.md @@ -0,0 +1,6 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor +--- + +Entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`) now returns entity-resolved `thresholds` and per-point `thresholdEvaluation` so clients can render sparkline legends and chart colors without a separate snapshot call. diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index 10d55ae4bc9..b16d2a31f06 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -309,6 +309,8 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service? Returns daily time-series points for one metric on a catalog entity. Each point is the latest sample (`MAX(id)` among success or calculation-error rows) for that UTC calendar day. On a mixed day the later sample wins, so a later error is returned as `{ "value": null, "error": "..." }` (and clients can gap a sparkline). Days with no rows (or only null without `error_message`) are omitted. Returns `200` with `points: []` when the entity and metric are authorized but no data exists in the range. +The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from pull-time evaluation (e.g. `success`, `warning`, `error`). Calculation-error points omit `thresholdEvaluation`. When the stored status is missing, `thresholdEvaluation` is `null`. + #### Path Parameters | Parameter | Type | Required | Description | @@ -350,14 +352,29 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service/ "defaultVisualization": "donut" }, "points": [ - { "value": 8, "timestamp": "2026-04-27T23:10:00.000Z" }, + { + "value": 8, + "timestamp": "2026-04-27T23:10:00.000Z", + "thresholdEvaluation": "success" + }, { "value": null, "timestamp": "2026-04-28T16:00:00.000Z", "error": "GitHub API 500" }, - { "value": 7, "timestamp": "2026-04-29T22:55:00.000Z" } - ] + { + "value": 25, + "timestamp": "2026-04-29T22:55:00.000Z", + "thresholdEvaluation": "warning" + } + ], + "thresholds": { + "rules": [ + { "key": "success", "expression": "<10" }, + { "key": "warning", "expression": "10-50" }, + { "key": "error", "expression": ">50" } + ] + } } ``` diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index 866f6ff74d2..f97fc7da982 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -532,13 +532,20 @@ describe('CatalogMetricService', () => { defaultVisualization: provider.getMetrics()[0].defaultVisualization, collectorIds: provider.getMetrics()[0].collectorIds, }, + thresholds: { rules: mockThresholdRules }, }); expect( mockedDatabase.readLatestEntityMetricValuesPerUtcDay, ).toHaveBeenCalledWith(entityRef, metricId, from, to); + expect( + mockedThresholdResolver.resolveEntityThresholds, + ).toHaveBeenCalledWith( + mockEntity, + expect.objectContaining({ id: metricId }), + ); }); - it('should map each daily DB row to a time-series point', async () => { + it('should map each daily DB row to a time-series point with thresholdEvaluation', async () => { mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ { id: 3, @@ -556,7 +563,7 @@ describe('CatalogMetricService', () => { value: 7, timestamp: new Date('2024-01-02T12:00:00.000Z'), errorMessage: null, - status: 'success', + status: 'warning', }, ] as DbMetricValue[]); @@ -568,12 +575,21 @@ describe('CatalogMetricService', () => { ); expect(result.points).toEqual([ - { value: 9, timestamp: '2024-01-01T20:00:00.000Z' }, - { value: 7, timestamp: '2024-01-02T12:00:00.000Z' }, + { + value: 9, + timestamp: '2024-01-01T20:00:00.000Z', + thresholdEvaluation: 'success', + }, + { + value: 7, + timestamp: '2024-01-02T12:00:00.000Z', + thresholdEvaluation: 'warning', + }, ]); + expect(result.thresholds).toEqual({ rules: mockThresholdRules }); }); - it('should map calculation-error rows to null value with error', async () => { + it('should map calculation-error rows to null value with error and omit thresholdEvaluation', async () => { mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ { id: 1, @@ -612,16 +628,68 @@ describe('CatalogMetricService', () => { ); expect(result.points).toEqual([ - { value: 8, timestamp: '2024-01-01T10:00:00.000Z' }, + { + value: 8, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: 'success', + }, { value: null, timestamp: '2024-01-02T16:00:00.000Z', error: 'GitHub API 500', }, - { value: 7, timestamp: '2024-01-03T10:00:00.000Z' }, + { + value: 7, + timestamp: '2024-01-03T10:00:00.000Z', + thresholdEvaluation: 'success', + }, ]); }); + it('should set thresholdEvaluation to null when DB status is null on success points', async () => { + mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: 5, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: null, + status: null, + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.points).toEqual([ + { + value: 5, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: null, + }, + ]); + }); + + it('should fall back to metric.thresholds when resolveEntityThresholds throws', async () => { + mockedThresholdResolver.resolveEntityThresholds.mockImplementation(() => { + throw new Error('Merge thresholds failed'); + }); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.thresholds).toEqual(provider.getMetrics()[0].thresholds); + }); + it('should pass permission filter to filterAuthorizedMetrics', async () => { await service.getEntityMetricTimeSeries( entityRef, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts index 922ad8e7743..66ba83720b6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -244,9 +244,20 @@ export class CatalogMetricService { return { value: row.value, timestamp: row.timestamp.toISOString(), + thresholdEvaluation: row.status ?? null, }; }); + let thresholds: ThresholdConfig; + try { + thresholds = this.thresholdResolver.resolveEntityThresholds( + entity, + metric, + ); + } catch { + thresholds = metric.thresholds; + } + return { metricId: metric.id, entityRef, @@ -260,6 +271,7 @@ export class CatalogMetricService { defaultVisualization: metric.defaultVisualization, collectorIds: metric.collectorIds, }, + thresholds, }; } 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 1dfa37b2035..0f9965dfca5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -654,9 +654,24 @@ describe('createRouter', () => { defaultVisualization: 'donut', }, points: [ - { value: 8, timestamp: '2024-01-01T20:00:00.000Z' }, - { value: 7, timestamp: '2024-01-02T12:00:00.000Z' }, + { + value: 8, + timestamp: '2024-01-01T20:00:00.000Z', + thresholdEvaluation: 'success', + }, + { + value: 7, + timestamp: '2024-01-02T12:00:00.000Z', + thresholdEvaluation: 'success', + }, ], + thresholds: { + rules: [ + { key: 'error', expression: '>40' }, + { key: 'warning', expression: '>20' }, + { key: 'success', expression: '<=20' }, + ], + }, }; const timeSeriesPath = diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index cd0d757694e..b27cece0e1c 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -181,6 +181,7 @@ export type MetricTimeSeriesPoint = { value: MetricValue | null; timestamp: string; error?: string; + thresholdEvaluation?: string | null; }; // @public @@ -197,6 +198,7 @@ export type MetricTimeSeriesResponse = { defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; + thresholds: ThresholdConfig; }; // @public (undocumented) diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts index 2189173db92..5b6c6d817c1 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts @@ -138,6 +138,11 @@ export type MetricTimeSeriesPoint = { timestamp: string; /** Present when this point is a calculation failure */ error?: string; + /** + * Matched threshold rule key from pull-time evaluation (e.g., "elite", "success", "warning"). + * `null` when the value could not be classified. Absent on calculation-error points. + */ + thresholdEvaluation?: string | null; }; /** @@ -157,4 +162,9 @@ export type MetricTimeSeriesResponse = { defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; + /** + * Entity-resolved threshold rules (provider defaults, then app-config, then entity annotation overrides). + * Used for sparkline legend rendering and mapping `thresholdEvaluation` keys to colors. + */ + thresholds: ThresholdConfig; }; From e8c7efa6ad0bf657ea01f86ec448ff804b65a2b8 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Thu, 10 Sep 2026 16:17:57 +0200 Subject: [PATCH 2/6] fix: calculate thresholdEvaluation from current config Signed-off-by: Diana Janickova --- .../.changeset/breezy-numbers-hide.md | 2 +- .../plugins/scorecard-backend/README.md | 2 +- .../src/service/CatalogMetricService.test.ts | 13 +++--- .../src/service/CatalogMetricService.ts | 43 ++++++++++++++----- .../scorecard-common/src/types/Metric.ts | 3 +- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/workspaces/scorecard/.changeset/breezy-numbers-hide.md b/workspaces/scorecard/.changeset/breezy-numbers-hide.md index 50e98260a1b..88527b024b5 100644 --- a/workspaces/scorecard/.changeset/breezy-numbers-hide.md +++ b/workspaces/scorecard/.changeset/breezy-numbers-hide.md @@ -3,4 +3,4 @@ '@red-hat-developer-hub/backstage-plugin-scorecard-common': minor --- -Entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`) now returns entity-resolved `thresholds` and per-point `thresholdEvaluation` so clients can render sparkline legends and chart colors without a separate snapshot call. +Entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`) now returns entity-resolved `thresholds` and per-point `thresholdEvaluation` (classified at read time against those current thresholds) so clients can render sparkline legends and chart colors without a separate snapshot call. diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index b16d2a31f06..89ab3f62a94 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -309,7 +309,7 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service? Returns daily time-series points for one metric on a catalog entity. Each point is the latest sample (`MAX(id)` among success or calculation-error rows) for that UTC calendar day. On a mixed day the later sample wins, so a later error is returned as `{ "value": null, "error": "..." }` (and clients can gap a sparkline). Days with no rows (or only null without `error_message`) are omitted. Returns `200` with `points: []` when the entity and metric are authorized but no data exists in the range. -The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from pull-time evaluation (e.g. `success`, `warning`, `error`). Calculation-error points omit `thresholdEvaluation`. When the stored status is missing, `thresholdEvaluation` is `null`. +The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from **read-time** evaluation of the point's `value` against those current `thresholds` (e.g. `success`, `warning`, `error`). This keeps legend keys and point classifications consistent when config changes. Calculation-error points omit `thresholdEvaluation`. When no rule matches, `thresholdEvaluation` is `null`. #### Path Parameters diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index f97fc7da982..5ce98ed054a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -545,7 +545,7 @@ describe('CatalogMetricService', () => { ); }); - it('should map each daily DB row to a time-series point with thresholdEvaluation', async () => { + it('should map each daily DB row to a time-series point with read-time thresholdEvaluation', async () => { mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ { id: 3, @@ -560,10 +560,11 @@ describe('CatalogMetricService', () => { id: 2, catalogEntityRef: entityRef, metricId: metricId, - value: 7, + value: 25, timestamp: new Date('2024-01-02T12:00:00.000Z'), errorMessage: null, - status: 'warning', + // Stale write-time status must be ignored in favor of read-time evaluation + status: 'success', }, ] as DbMetricValue[]); @@ -581,7 +582,7 @@ describe('CatalogMetricService', () => { thresholdEvaluation: 'success', }, { - value: 7, + value: 25, timestamp: '2024-01-02T12:00:00.000Z', thresholdEvaluation: 'warning', }, @@ -646,7 +647,7 @@ describe('CatalogMetricService', () => { ]); }); - it('should set thresholdEvaluation to null when DB status is null on success points', async () => { + it('should evaluate thresholdEvaluation from current thresholds even when DB status is null', async () => { mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ { id: 1, @@ -670,7 +671,7 @@ describe('CatalogMetricService', () => { { value: 5, timestamp: '2024-01-01T10:00:00.000Z', - thresholdEvaluation: null, + thresholdEvaluation: 'success', }, ]); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts index 66ba83720b6..e2b8ba01f8c 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -50,6 +50,7 @@ import { isMetricCalculationError } from '../utils/metricCalculationError'; import { AggregatedMetricMapper } from './mappers'; import { DbMetricValue } from '../database/types'; import { ThresholdResolver } from '../threshold/ThresholdResolver'; +import { ThresholdEvaluator } from '../threshold/ThresholdEvaluator'; type CatalogMetricServiceOptions = { catalog: CatalogService; @@ -82,6 +83,7 @@ export class CatalogMetricService { private readonly registry: MetricProvidersRegistry; private readonly database: DatabaseMetricValues; private readonly thresholdResolver: ThresholdResolver; + private readonly thresholdEvaluator = new ThresholdEvaluator(); private static readonly MAX_FETCHABLE_ROWS = 10_000; private static readonly BATCH_SIZE = 100; @@ -233,6 +235,16 @@ export class CatalogMetricService { to, ); + let thresholds: ThresholdConfig; + try { + thresholds = this.thresholdResolver.resolveEntityThresholds( + entity, + metric, + ); + } catch { + thresholds = metric.thresholds; + } + const points: MetricTimeSeriesPoint[] = rows.map(row => { if (isMetricCalculationError(row)) { return { @@ -241,23 +253,32 @@ export class CatalogMetricService { error: row.errorMessage!, }; } + + let thresholdEvaluation: string | null = null; + if (row.value !== null) { + try { + thresholdEvaluation = + this.thresholdEvaluator.getFirstMatchingThreshold( + row.value, + metric.type, + thresholds, + ) ?? null; + } catch (error) { + this.logger.warn( + `Failed to evaluate thresholds for metric '${ + metric.id + }' on entity '${entityRef}': ${stringifyError(error)}`, + ); + } + } + return { value: row.value, timestamp: row.timestamp.toISOString(), - thresholdEvaluation: row.status ?? null, + thresholdEvaluation, }; }); - let thresholds: ThresholdConfig; - try { - thresholds = this.thresholdResolver.resolveEntityThresholds( - entity, - metric, - ); - } catch { - thresholds = metric.thresholds; - } - return { metricId: metric.id, entityRef, diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts index 5b6c6d817c1..29af1e68731 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts @@ -139,7 +139,8 @@ export type MetricTimeSeriesPoint = { /** Present when this point is a calculation failure */ error?: string; /** - * Matched threshold rule key from pull-time evaluation (e.g., "elite", "success", "warning"). + * Matched threshold rule key from read-time evaluation against the response + * `thresholds` (e.g., "elite", "success", "warning"). * `null` when the value could not be classified. Absent on calculation-error points. */ thresholdEvaluation?: string | null; From b18c0ddbe0f7d93e0952204f96baf1bf2a791ff7 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Thu, 10 Sep 2026 16:25:51 +0200 Subject: [PATCH 3/6] fix: fallback to resolveMetricThresholds Signed-off-by: Diana Janickova --- .../src/service/CatalogMetricService.test.ts | 16 ++++++++++++++-- .../src/service/CatalogMetricService.ts | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index 5ce98ed054a..523f0facf99 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -676,10 +676,19 @@ describe('CatalogMetricService', () => { ]); }); - it('should fall back to metric.thresholds when resolveEntityThresholds throws', async () => { + it('should fall back to resolveMetricThresholds when resolveEntityThresholds throws', async () => { + const metricThresholds = { + rules: [ + { key: 'success', expression: '<5' }, + { key: 'error', expression: '>=5' }, + ], + }; mockedThresholdResolver.resolveEntityThresholds.mockImplementation(() => { throw new Error('Merge thresholds failed'); }); + mockedThresholdResolver.resolveMetricThresholds.mockReturnValue( + metricThresholds, + ); const result = await service.getEntityMetricTimeSeries( entityRef, @@ -688,7 +697,10 @@ describe('CatalogMetricService', () => { to, ); - expect(result.thresholds).toEqual(provider.getMetrics()[0].thresholds); + expect( + mockedThresholdResolver.resolveMetricThresholds, + ).toHaveBeenCalledWith(expect.objectContaining({ id: metricId })); + expect(result.thresholds).toEqual(metricThresholds); }); it('should pass permission filter to filterAuthorizedMetrics', async () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts index e2b8ba01f8c..5fd9bbcb192 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -242,7 +242,8 @@ export class CatalogMetricService { metric, ); } catch { - thresholds = metric.thresholds; + // Keep app-config / provider thresholds when entity annotation merge fails + thresholds = this.thresholdResolver.resolveMetricThresholds(metric); } const points: MetricTimeSeriesPoint[] = rows.map(row => { From 28c827fc3bc0050a30aee8e7d0cd64f1ee1234ed Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Mon, 14 Sep 2026 13:05:10 +0200 Subject: [PATCH 4/6] feat: return thresholdsError in time series response Signed-off-by: Diana Janickova --- .../.changeset/breezy-numbers-hide.md | 2 +- .../plugins/scorecard-backend/README.md | 8 +- .../scorecard-backend/docs/thresholds.md | 2 +- .../src/service/CatalogMetricService.test.ts | 129 ++++++++++++++++-- .../src/service/CatalogMetricService.ts | 30 ++-- .../plugins/scorecard-common/report.api.md | 3 +- .../scorecard-common/src/types/Metric.ts | 16 ++- 7 files changed, 160 insertions(+), 30 deletions(-) diff --git a/workspaces/scorecard/.changeset/breezy-numbers-hide.md b/workspaces/scorecard/.changeset/breezy-numbers-hide.md index 88527b024b5..2cb7ed9b761 100644 --- a/workspaces/scorecard/.changeset/breezy-numbers-hide.md +++ b/workspaces/scorecard/.changeset/breezy-numbers-hide.md @@ -3,4 +3,4 @@ '@red-hat-developer-hub/backstage-plugin-scorecard-common': minor --- -Entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`) now returns entity-resolved `thresholds` and per-point `thresholdEvaluation` (classified at read time against those current thresholds) so clients can render sparkline legends and chart colors without a separate snapshot call. +Entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`) now returns entity-resolved `thresholds` and per-point `thresholdEvaluation` (classified at read time against those current thresholds) so clients can render sparkline legends and chart colors without a separate snapshot call. Threshold evaluation failures are returned in the existing per-point `error` field. When entity threshold resolution fails (e.g. malformed annotation overrides), the response sets `thresholdsError` and omits `thresholds` instead of silently falling back to config/provider defaults; points are left unclassified (`thresholdEvaluation` null). diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index 89ab3f62a94..8c29f945461 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -309,7 +309,7 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service? Returns daily time-series points for one metric on a catalog entity. Each point is the latest sample (`MAX(id)` among success or calculation-error rows) for that UTC calendar day. On a mixed day the later sample wins, so a later error is returned as `{ "value": null, "error": "..." }` (and clients can gap a sparkline). Days with no rows (or only null without `error_message`) are omitted. Returns `200` with `points: []` when the entity and metric are authorized but no data exists in the range. -The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from **read-time** evaluation of the point's `value` against those current `thresholds` (e.g. `success`, `warning`, `error`). This keeps legend keys and point classifications consistent when config changes. Calculation-error points omit `thresholdEvaluation`. When no rule matches, `thresholdEvaluation` is `null`. +The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from **read-time** evaluation of the point's `value` against those current `thresholds` (e.g. `success`, `warning`, `error`). This keeps legend keys and point classifications consistent when config changes. Calculation-error points omit `thresholdEvaluation`. When no rule matches, `thresholdEvaluation` is `null`. Threshold evaluation failures set that point's `error` (with `thresholdEvaluation` `null`). #### Path Parameters @@ -366,6 +366,12 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service/ "value": 25, "timestamp": "2026-04-29T22:55:00.000Z", "thresholdEvaluation": "warning" + }, + { + "value": 12, + "timestamp": "2026-04-30T18:00:00.000Z", + "thresholdEvaluation": null, + "error": "Error: Invalid threshold expression" } ], "thresholds": { diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md index d8af4cef6ee..c4f35d76939 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -473,7 +473,7 @@ The `ThresholdEvaluator` service processes threshold rules and determines which 1. **Order-dependent evaluation**: Rules are evaluated in the order they appear. If provider supports overriding defaults through [app configuration](#App-Configuration-Thresholds), you can change the evaluation order by specifying threshold keys in a different order. Entity annotations cannot alter the evaluation order, which is determined by either the [app configuration](#Provider-Default-Thresholds) or, if not specified, the [default provider configuration](#Provider-Default-Thresholds). 2. **First-match wins**: Returns the first threshold rule whose condition the value satisfies 3. **Type-safe**: Validates expressions against metric types -4. **Error handling**: Thresholds from providers and custom thresholds from configuration are validated on startup (using [validateThresholdsForMetric](../../scorecard-node/src/utils/thresholds/validateThresholds.ts) from `@red-hat-developer-hub/backstage-plugin-scorecard-node`). Threshold errors caused by invalid providers or invalid configuration cause startup failures. Annotation-based threshold errors are reported in the UI at evaluation time. +4. **Error handling**: Thresholds from providers and custom thresholds from configuration are validated on startup (using [validateThresholdsForMetric](../../scorecard-node/src/utils/thresholds/validateThresholds.ts) from `@red-hat-developer-hub/backstage-plugin-scorecard-node`). Threshold errors caused by invalid providers or invalid configuration cause startup failures. Annotation-based threshold errors are reported in the UI at evaluation time. On the entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`), threshold evaluation failures are returned in that point's `error` field (with `thresholdEvaluation` `null`). ### Best Practices diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index 523f0facf99..22cd4fffbd9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -52,6 +52,7 @@ import { AggregatedMetricMapper } from './mappers'; import { AggregatedMetricLoader } from './aggregations/AggregatedMetricLoader'; import { DatabaseMetricValues } from '../database/DatabaseMetricValues'; import { ThresholdResolver } from '../threshold/ThresholdResolver'; +import { ThresholdEvaluator } from '../threshold/ThresholdEvaluator'; jest.mock('../permissions/permissionUtils'); @@ -676,19 +677,62 @@ describe('CatalogMetricService', () => { ]); }); - it('should fall back to resolveMetricThresholds when resolveEntityThresholds throws', async () => { - const metricThresholds = { - rules: [ - { key: 'success', expression: '<5' }, - { key: 'error', expression: '>=5' }, - ], - }; + it('should set error on the point when threshold evaluation fails', async () => { + jest + .spyOn(ThresholdEvaluator.prototype, 'getFirstMatchingThreshold') + .mockImplementation(() => { + throw new Error('Invalid threshold expression'); + }); + + mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: 5, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: null, + status: null, + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.points).toEqual([ + { + value: 5, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: null, + error: 'Error: Invalid threshold expression', + }, + ]); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to evaluate thresholds for metric '${metricId}' on entity '${entityRef}'`, + ), + ); + }); + + it('should set thresholdsError and skip classification when resolveEntityThresholds throws', async () => { mockedThresholdResolver.resolveEntityThresholds.mockImplementation(() => { throw new Error('Merge thresholds failed'); }); - mockedThresholdResolver.resolveMetricThresholds.mockReturnValue( - metricThresholds, - ); + mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: 5, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: null, + status: null, + }, + ] as DbMetricValue[]); const result = await service.getEntityMetricTimeSeries( entityRef, @@ -699,8 +743,69 @@ describe('CatalogMetricService', () => { expect( mockedThresholdResolver.resolveMetricThresholds, - ).toHaveBeenCalledWith(expect.objectContaining({ id: metricId })); - expect(result.thresholds).toEqual(metricThresholds); + ).not.toHaveBeenCalled(); + expect(result.thresholds).toBeUndefined(); + expect(result.thresholdsError).toBe('Error: Merge thresholds failed'); + expect(result.points).toEqual([ + { + value: 5, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: null, + }, + ]); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to resolve thresholds for metric '${metricId}' on entity '${entityRef}'`, + ), + ); + }); + + it('should keep calculation-error point errors when resolveEntityThresholds throws', async () => { + mockedThresholdResolver.resolveEntityThresholds.mockImplementation(() => { + throw new Error('Merge thresholds failed'); + }); + mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: 5, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: null, + status: null, + }, + { + id: 2, + catalogEntityRef: entityRef, + metricId: metricId, + value: null, + timestamp: new Date('2024-01-02T16:00:00.000Z'), + errorMessage: 'GitHub API 500', + status: null, + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.thresholds).toBeUndefined(); + expect(result.thresholdsError).toBe('Error: Merge thresholds failed'); + expect(result.points).toEqual([ + { + value: 5, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: null, + }, + { + value: null, + timestamp: '2024-01-02T16:00:00.000Z', + error: 'GitHub API 500', + }, + ]); }); it('should pass permission filter to filterAuthorizedMetrics', async () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts index 5fd9bbcb192..11d6e6f2805 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -196,7 +196,10 @@ export class CatalogMetricService { * * Returns at most one point per UTC calendar day: the latest sample * (`MAX(id)`), whether success or calculation error. Calculation failures - * use `value: null` and `error`. + * use `value: null` and `error`. Threshold evaluation failures also set + * `error` (with `thresholdEvaluation` null). When entity threshold + * resolution fails, `thresholdsError` is set on the response and points are + * not classified (`thresholdEvaluation` null, no per-point `error`). * * @param entityRef - Entity reference in format "kind:namespace/name" * @param metricId - Metric ID to fetch @@ -235,15 +238,18 @@ export class CatalogMetricService { to, ); - let thresholds: ThresholdConfig; + let thresholds: ThresholdConfig | undefined; + let thresholdsError: string | undefined; try { thresholds = this.thresholdResolver.resolveEntityThresholds( entity, metric, ); - } catch { - // Keep app-config / provider thresholds when entity annotation merge fails - thresholds = this.thresholdResolver.resolveMetricThresholds(metric); + } catch (err) { + thresholdsError = stringifyError(err); + this.logger.warn( + `Failed to resolve thresholds for metric '${metric.id}' on entity '${entityRef}': ${thresholdsError}`, + ); } const points: MetricTimeSeriesPoint[] = rows.map(row => { @@ -256,7 +262,8 @@ export class CatalogMetricService { } let thresholdEvaluation: string | null = null; - if (row.value !== null) { + let error: string | undefined; + if (row.value !== null && thresholds) { try { thresholdEvaluation = this.thresholdEvaluator.getFirstMatchingThreshold( @@ -264,11 +271,10 @@ export class CatalogMetricService { metric.type, thresholds, ) ?? null; - } catch (error) { + } catch (err) { + error = stringifyError(err); this.logger.warn( - `Failed to evaluate thresholds for metric '${ - metric.id - }' on entity '${entityRef}': ${stringifyError(error)}`, + `Failed to evaluate thresholds for metric '${metric.id}' on entity '${entityRef}': ${error}`, ); } } @@ -277,6 +283,7 @@ export class CatalogMetricService { value: row.value, timestamp: row.timestamp.toISOString(), thresholdEvaluation, + ...(error ? { error } : {}), }; }); @@ -293,7 +300,8 @@ export class CatalogMetricService { defaultVisualization: metric.defaultVisualization, collectorIds: metric.collectorIds, }, - thresholds, + ...(thresholds ? { thresholds } : {}), + ...(thresholdsError ? { thresholdsError } : {}), }; } diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index b27cece0e1c..88f6dad5b49 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -198,7 +198,8 @@ export type MetricTimeSeriesResponse = { defaultVisualization?: ScorecardVisualizationType; collectorIds?: string[]; }; - thresholds: ThresholdConfig; + thresholds?: ThresholdConfig; + thresholdsError?: string; }; // @public (undocumented) diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts index 29af1e68731..ce4e7ca3bee 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/Metric.ts @@ -129,14 +129,18 @@ export type EntityMetricDetailResponse = { * A single sample in a metric time series (latest value for a UTC day). * Success points have a non-null `value`. When the latest sample is a * calculation failure, `value` is `null` and `error` is set to the failure - * message. + * message. Threshold evaluation failures also set `error` (with + * `thresholdEvaluation` null). * @public */ export type MetricTimeSeriesPoint = { value: MetricValue | null; /** ISO-8601 timestamp of the chosen sample */ timestamp: string; - /** Present when this point is a calculation failure */ + /** + * Present when this point is a calculation failure or threshold evaluation + * failed. + */ error?: string; /** * Matched threshold rule key from read-time evaluation against the response @@ -166,6 +170,12 @@ export type MetricTimeSeriesResponse = { /** * Entity-resolved threshold rules (provider defaults, then app-config, then entity annotation overrides). * Used for sparkline legend rendering and mapping `thresholdEvaluation` keys to colors. + * Undefined when entity threshold resolution failed (see `thresholdsError`). */ - thresholds: ThresholdConfig; + thresholds?: ThresholdConfig; + /** + * Set when entity threshold resolution failed (e.g. malformed entity annotation overrides). + * When present, points are not classified (`thresholdEvaluation` is null). + */ + thresholdsError?: string; }; From 9352f0d79ccdd5bdc355b2b872ae1bf2b9f4b3f1 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Mon, 14 Sep 2026 13:07:40 +0200 Subject: [PATCH 5/6] docs: update documentation Signed-off-by: Diana Janickova --- workspaces/scorecard/plugins/scorecard-backend/README.md | 4 +++- .../scorecard/plugins/scorecard-backend/docs/thresholds.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index 8c29f945461..219416d4b52 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -309,7 +309,9 @@ curl -X GET "{{url}}/api/scorecard/metrics/catalog/component/default/my-service? Returns daily time-series points for one metric on a catalog entity. Each point is the latest sample (`MAX(id)` among success or calculation-error rows) for that UTC calendar day. On a mixed day the later sample wins, so a later error is returned as `{ "value": null, "error": "..." }` (and clients can gap a sparkline). Days with no rows (or only null without `error_message`) are omitted. Returns `200` with `points: []` when the entity and metric are authorized but no data exists in the range. -The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from **read-time** evaluation of the point's `value` against those current `thresholds` (e.g. `success`, `warning`, `error`). This keeps legend keys and point classifications consistent when config changes. Calculation-error points omit `thresholdEvaluation`. When no rule matches, `thresholdEvaluation` is `null`. Threshold evaluation failures set that point's `error` (with `thresholdEvaluation` `null`). +The response also includes entity-resolved `thresholds` (provider defaults, then app-config, then entity annotation overrides) for sparkline legend rendering. Successful points include `thresholdEvaluation`: the matched threshold rule key from **read-time** evaluation of the point's `value` against those current `thresholds` (e.g. `success`, `warning`, `error`). This keeps legend keys and point classifications consistent when config changes. Calculation-error points omit `thresholdEvaluation`. When no rule matches, `thresholdEvaluation` is `null` (no per-point `error`). Threshold evaluation failures set that point's `error` (with `thresholdEvaluation` `null`). + +When entity threshold resolution fails (e.g. malformed annotation overrides), the response omits `thresholds`, sets top-level `thresholdsError` with the failure message, and leaves successful points unclassified (`thresholdEvaluation` `null`, no per-point `error`). There is no silent fallback to app-config / provider defaults. #### Path Parameters diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md index c4f35d76939..3ba038aad2a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -473,7 +473,7 @@ The `ThresholdEvaluator` service processes threshold rules and determines which 1. **Order-dependent evaluation**: Rules are evaluated in the order they appear. If provider supports overriding defaults through [app configuration](#App-Configuration-Thresholds), you can change the evaluation order by specifying threshold keys in a different order. Entity annotations cannot alter the evaluation order, which is determined by either the [app configuration](#Provider-Default-Thresholds) or, if not specified, the [default provider configuration](#Provider-Default-Thresholds). 2. **First-match wins**: Returns the first threshold rule whose condition the value satisfies 3. **Type-safe**: Validates expressions against metric types -4. **Error handling**: Thresholds from providers and custom thresholds from configuration are validated on startup (using [validateThresholdsForMetric](../../scorecard-node/src/utils/thresholds/validateThresholds.ts) from `@red-hat-developer-hub/backstage-plugin-scorecard-node`). Threshold errors caused by invalid providers or invalid configuration cause startup failures. Annotation-based threshold errors are reported in the UI at evaluation time. On the entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`), threshold evaluation failures are returned in that point's `error` field (with `thresholdEvaluation` `null`). +4. **Error handling**: Thresholds from providers and custom thresholds from configuration are validated on startup (using [validateThresholdsForMetric](../../scorecard-node/src/utils/thresholds/validateThresholds.ts) from `@red-hat-developer-hub/backstage-plugin-scorecard-node`). Threshold errors caused by invalid providers or invalid configuration cause startup failures. Annotation-based threshold errors are reported in the UI at evaluation time. On the entity time-series API (`GET /metrics/catalog/:kind/:namespace/:name/time-series`), threshold **evaluation** failures are returned in that point's `error` field (with `thresholdEvaluation` `null`). When entity threshold **resolution** fails (e.g. malformed annotation overrides), the response sets top-level `thresholdsError`, omits `thresholds` (no fallback to app-config / provider defaults), and leaves points unclassified (`thresholdEvaluation` `null`, no per-point `error`). ### Best Practices From fb27175c5bde57007d8b9f2a5f8a981e37e00f40 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 15 Sep 2026 12:38:27 +0200 Subject: [PATCH 6/6] test: improve test clarity, cover 0 and false Signed-off-by: Diana Janickova --- .../src/service/CatalogMetricService.test.ts | 79 ++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index 22cd4fffbd9..fe0de7102ab 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -21,6 +21,7 @@ import { CatalogMetricService } from './CatalogMetricService'; import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; import { MockNumberProvider, + MockBooleanProvider, filecheckBatchProvider, filecheckBatchMetrics, } from '../../__fixtures__/mockProviders'; @@ -591,6 +592,78 @@ describe('CatalogMetricService', () => { expect(result.thresholds).toEqual({ rules: mockThresholdRules }); }); + it('should classify number value 0 instead of leaving thresholdEvaluation null', async () => { + mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: metricId, + value: 0, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + metricId, + from, + to, + ); + + expect(result.points).toEqual([ + { + value: 0, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: 'success', + }, + ]); + }); + + it('should classify boolean value false instead of leaving thresholdEvaluation null', async () => { + const booleanProvider = new MockBooleanProvider( + 'jira.booleanMetric', + 'jira', + ); + const booleanMetric = booleanProvider.getMetrics()[0]; + const booleanThresholds = booleanProvider.getDefaultThresholds(); + + mockedRegistry.getMetric.mockReturnValue(booleanMetric); + (permissionUtils.filterAuthorizedMetrics as jest.Mock).mockReturnValue([ + booleanMetric, + ]); + mockedThresholdResolver.resolveEntityThresholds.mockReturnValue( + booleanThresholds, + ); + mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ + { + id: 1, + catalogEntityRef: entityRef, + metricId: booleanMetric.id, + value: false, + timestamp: new Date('2024-01-01T10:00:00.000Z'), + errorMessage: null, + status: 'success', + }, + ] as DbMetricValue[]); + + const result = await service.getEntityMetricTimeSeries( + entityRef, + booleanMetric.id, + from, + to, + ); + + expect(result.points).toEqual([ + { + value: false, + timestamp: '2024-01-01T10:00:00.000Z', + thresholdEvaluation: 'error', + }, + ]); + }); + it('should map calculation-error rows to null value with error and omit thresholdEvaluation', async () => { mockedDatabase.readLatestEntityMetricValuesPerUtcDay.mockResolvedValue([ { @@ -692,7 +765,7 @@ describe('CatalogMetricService', () => { value: 5, timestamp: new Date('2024-01-01T10:00:00.000Z'), errorMessage: null, - status: null, + status: 'success', }, ] as DbMetricValue[]); @@ -730,7 +803,7 @@ describe('CatalogMetricService', () => { value: 5, timestamp: new Date('2024-01-01T10:00:00.000Z'), errorMessage: null, - status: null, + status: 'success', }, ] as DbMetricValue[]); @@ -772,7 +845,7 @@ describe('CatalogMetricService', () => { value: 5, timestamp: new Date('2024-01-01T10:00:00.000Z'), errorMessage: null, - status: null, + status: 'success', }, { id: 2,