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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions workspaces/scorecard/.changeset/string-metric-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-node': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-backend': patch
---

Add string as an alternative MetricType and migrate CatalogRequiredAttributesMetricProvider from number to string metrics.
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {

expect(metrics).toHaveLength(1);
metrics?.forEach(m => {
expect(m.type).toBe('number');
expect(m.type).toBe('string');
});
});

Expand Down Expand Up @@ -472,22 +472,16 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
});

describe('calculateMetrics', () => {
it('should return "found" status code for existing field', async () => {
it('should return "found" status string for existing field', async () => {
const provider = createCatalogRequiredAttributesMetricProvider(
new ConfigReader(buildConfig({ title: titleMetric() })),
);
const result = await provider?.calculateMetrics(componentEntity);

// The metric value is a numeric code mapping to "found"
const metrics = provider?.getMetrics();
const titleMet = metrics?.find(m => m.id === 'catalog.title');
const foundRule = titleMet?.thresholds.rules.find(r => r.key === 'found');
const expectedCode = Number(foundRule?.expression.replace('==', ''));

expect(result?.get('catalog.title')).toBe(expectedCode);
expect(result?.get('catalog.title')).toBe('found');
});

it('should return "missed" status code for missing field', async () => {
it('should return "missed" status string for missing field', async () => {
const entityWithoutTitle: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
Expand All @@ -499,14 +493,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
);
const result = await provider?.calculateMetrics(entityWithoutTitle);

const metrics = provider?.getMetrics();
const titleMet = metrics?.find(m => m.id === 'catalog.title');
const missedRule = titleMet?.thresholds.rules.find(
r => r.key === 'missed',
);
const expectedCode = Number(missedRule?.expression.replace('==', ''));

expect(result?.get('catalog.title')).toBe(expectedCode);
expect(result?.get('catalog.title')).toBe('missed');
});

it('should return "ok" for valid lifecycle value', async () => {
Expand All @@ -515,12 +502,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
);
const result = await provider?.calculateMetrics(componentEntity);

const metrics = provider?.getMetrics();
const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle');
const okRule = lcMetric?.thresholds.rules.find(r => r.key === 'ok');
const expectedCode = Number(okRule?.expression.replace('==', ''));

expect(result?.get('catalog.lifecycle')).toBe(expectedCode);
expect(result?.get('catalog.lifecycle')).toBe('ok');
});

it('should return "invalid" for unknown lifecycle value', async () => {
Expand All @@ -533,14 +515,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
);
const result = await provider?.calculateMetrics(entity);

const metrics = provider?.getMetrics();
const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle');
const invalidRule = lcMetric?.thresholds.rules.find(
r => r.key === 'invalid',
);
const expectedCode = Number(invalidRule?.expression.replace('==', ''));

expect(result?.get('catalog.lifecycle')).toBe(expectedCode);
expect(result?.get('catalog.lifecycle')).toBe('invalid');
});

it('should return "missed" for missing lifecycle value', async () => {
Expand All @@ -553,14 +528,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
);
const result = await provider?.calculateMetrics(entity);

const metrics = provider?.getMetrics();
const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle');
const missedRule = lcMetric?.thresholds.rules.find(
r => r.key === 'missed',
);
const expectedCode = Number(missedRule?.expression.replace('==', ''));

expect(result?.get('catalog.lifecycle')).toBe(expectedCode);
expect(result?.get('catalog.lifecycle')).toBe('missed');
});

it('should handle empty string field with default mapping', async () => {
Expand All @@ -574,14 +542,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
const result = await provider?.calculateMetrics(entity);

// Default mapping: emptyString → 'missed'
const metrics = provider?.getMetrics();
const titleMet = metrics?.find(m => m.id === 'catalog.title');
const missedRule = titleMet?.thresholds.rules.find(
r => r.key === 'missed',
);
const expectedCode = Number(missedRule?.expression.replace('==', ''));

expect(result?.get('catalog.title')).toBe(expectedCode);
expect(result?.get('catalog.title')).toBe('missed');
});

it('should handle empty array field with default mapping', async () => {
Expand All @@ -602,14 +563,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => {
);
const result = await provider?.calculateMetrics(entity);

const metrics = provider?.getMetrics();
const tagsMetric = metrics?.find(m => m.id === 'catalog.tags');
const missedRule = tagsMetric?.thresholds.rules.find(
r => r.key === 'missed',
);
const expectedCode = Number(missedRule?.expression.replace('==', ''));

expect(result?.get('catalog.tags')).toBe(expectedCode);
expect(result?.get('catalog.tags')).toBe('missed');
});

it('should handle multiple metrics on the same entity', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,28 +113,20 @@ function collectDistinctStatuses(statusMapping: StatusMapping): string[] {
}

/**
* Builds a mapping from status strings to numeric codes and generates
* threshold rules that map those codes back to status strings.
* Builds threshold rules for string metrics from the distinct statuses
* in a status mapping. Each status gets an `==statusKey` expression.
*/
function buildStatusCodeMapping(statusMapping: StatusMapping): {
statusToCode: Map<string, number>;
thresholds: ThresholdConfig;
} {
function buildStringThresholds(statusMapping: StatusMapping): ThresholdConfig {
const statuses = collectDistinctStatuses(statusMapping);
const statusToCode = new Map<string, number>();

statuses.forEach((status, index) => {
statusToCode.set(status, index);
});

const rules = statuses.map((status, index) => ({
const rules = statuses.map(status => ({
key: status,
expression: `==${index}`,
expression: `==${status}`,
color: getDefaultColor(status),
icon: getDefaultIcon(status),
}));

return { statusToCode, thresholds: { rules } };
return { rules };
}

/**
Expand Down Expand Up @@ -180,23 +172,20 @@ function getDefaultIcon(status: string): string {
}

export class CatalogRequiredAttributesMetricProvider
implements MetricProvider<'number'>
implements MetricProvider<'string'>
{
private readonly filter: object;
private readonly metricConfigs: MetricConfig[];
private readonly statusCodeMappings: Map<
string,
{ statusToCode: Map<string, number>; thresholds: ThresholdConfig }
>;
private readonly thresholdsByMetricId: Map<string, ThresholdConfig>;

constructor(options: CatalogRequiredAttributesOptions) {
this.filter = options.filter;
this.metricConfigs = options.metrics;
this.statusCodeMappings = new Map();
this.thresholdsByMetricId = new Map();
for (const metric of this.metricConfigs) {
this.statusCodeMappings.set(
this.thresholdsByMetricId.set(
metric.id,
buildStatusCodeMapping(metric.statusMapping),
buildStringThresholds(metric.statusMapping),
);
}
}
Expand All @@ -209,15 +198,15 @@ export class CatalogRequiredAttributesMetricProvider
return 'catalog.requiredAttributes';
}

getMetrics(): Metric<'number'>[] {
getMetrics(): Metric<'string'>[] {
return this.metricConfigs.map(metric => {
const mapping = this.statusCodeMappings.get(metric.id)!;
const thresholds = this.thresholdsByMetricId.get(metric.id)!;
return {
id: `catalog.${metric.id}`,
title: metric.title,
description: metric.description,
type: 'number' as const,
thresholds: mapping.thresholds,
type: 'string' as const,
thresholds,
};
});
}
Expand All @@ -226,8 +215,8 @@ export class CatalogRequiredAttributesMetricProvider
return this.filter as Record<string, string | symbol | (string | symbol)[]>;
}

async calculateMetrics(entity: Entity): Promise<Map<string, number>> {
const results = new Map<string, number>();
async calculateMetrics(entity: Entity): Promise<Map<string, string>> {
const results = new Map<string, string>();

for (const metric of this.metricConfigs) {
const status = evaluateFieldStatus(
Expand All @@ -236,11 +225,7 @@ export class CatalogRequiredAttributesMetricProvider
metric.statusMapping,
);

const mapping = this.statusCodeMappings.get(metric.id)!;
const code = mapping.statusToCode.get(status);
if (code !== undefined) {
results.set(`catalog.${metric.id}`, code);
}
results.set(`catalog.${metric.id}`, status);
}

return results;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ export const createGetEntityMetricsAction = ({
metadata: z.object({
title: z.string(),
description: z.string(),
type: z.enum(['number', 'boolean']),
type: z.enum(['number', 'boolean', 'string']),
unit: z.string().optional(),
history: z.boolean().optional(),
defaultVisualization: z.enum(['value', 'sparkline']).optional(),
}),
result: z.object({
value: z.union([z.number(), z.boolean(), z.null()]),
value: z.union([z.number(), z.boolean(), z.string(), z.null()]),
timestamp: z.string(),
thresholdResult: z.object({
definition: z.unknown().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const createListMetricsAction = ({
id: z.string(),
title: z.string(),
description: z.string(),
type: z.enum(['number', 'boolean']),
type: z.enum(['number', 'boolean', 'string']),
unit: z.string().optional(),
history: z.boolean().optional(),
defaultVisualization: z.enum(['value', 'sparkline']).optional(),
Expand Down
6 changes: 4 additions & 2 deletions workspaces/scorecard/plugins/scorecard-common/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export type EntityMetricDetail = {
entityNamespace?: string;
entityKind?: string;
owner?: string;
metricValue?: number | boolean | null;
metricValue?: number | boolean | string | null;
timestamp?: string;
status?: string | null;
};
Expand Down Expand Up @@ -188,13 +188,15 @@ export type MetricTimeSeriesResponse = {
};

// @public (undocumented)
export type MetricType = 'number' | 'boolean';
export type MetricType = 'number' | 'boolean' | 'string';

// @public (undocumented)
export type MetricValue<T extends MetricType = MetricType> = T extends 'number'
? number
: T extends 'boolean'
? boolean
: T extends 'string'
? string
: never;

// @public (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ThresholdConfig, ThresholdResult } from './threshold';
/**
* @public
*/
export type MetricType = 'number' | 'boolean';
export type MetricType = 'number' | 'boolean' | 'string';

/**
* Default visualization for a metric on the entity scorecard.
Expand All @@ -36,6 +36,8 @@ export type MetricValue<T extends MetricType = MetricType> = T extends 'number'
? number
: T extends 'boolean'
? boolean
: T extends 'string'
? string
: never;

/**
Expand Down Expand Up @@ -84,7 +86,7 @@ export type EntityMetricDetail = {
entityNamespace?: string;
entityKind?: string;
owner?: string;
metricValue?: number | boolean | null;
metricValue?: number | boolean | string | null;
timestamp?: string;
status?: string | null;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,44 @@ describe('parseThresholdExpression', () => {
});
});

describe('parseThresholdExpression - string metrics', () => {
it.each([
{
expression: '==found',
expectedResult: { operator: '==', value: 'found' },
},
{
expression: '==missed',
expectedResult: { operator: '==', value: 'missed' },
},
{
expression: '!=invalid',
expectedResult: { operator: '!=', value: 'invalid' },
},
{
expression: '==ok',
expectedResult: { operator: '==', value: 'ok' },
},
])(
'should parse string expression $expression correctly',
({ expression, expectedResult }) => {
const result = parseThresholdExpression(expression, 'string');
expect(result).toEqual(expectedResult);
},
);

it('should handle whitespace in string expressions', () => {
const result = parseThresholdExpression(' == found ', 'string');
expect(result).toEqual({ operator: '==', value: 'found' });
});

it('should reject range expressions for string metrics', () => {
expect(() => parseThresholdExpression('10-60', 'string')).toThrow(
ThresholdConfigFormatError,
);
});
});

describe('parseThresholdExpression - error handling', () => {
it.each([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ function parseComparisonOperator(
);
}

if (targetType === 'string') {
return { operator, value: valueStr };
}

return undefined;
}

Expand Down
Loading