Skip to content

Commit 4e4e02a

Browse files
committed
tech-insights: ability to add filters on the checks
Signed-off-by: surajnarwade <[email protected]>
1 parent a872eb7 commit 4e4e02a

File tree

7 files changed

+218
-3
lines changed

7 files changed

+218
-3
lines changed

workspaces/tech-insights/plugins/tech-insights-backend-module-jsonfc/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"@backstage-community/plugin-tech-insights-common": "workspace:^",
4343
"@backstage-community/plugin-tech-insights-node": "workspace:^",
4444
"@backstage/backend-plugin-api": "^1.3.1",
45+
"@backstage/catalog-client": "^1.10.0",
46+
"@backstage/catalog-model": "^1.7.4",
4547
"@backstage/config": "^1.3.2",
4648
"@backstage/errors": "^1.2.7",
4749
"@backstage/types": "^1.2.1",

workspaces/tech-insights/plugins/tech-insights-backend-module-jsonfc/src/module/techInsightsModuleJsonRulesEngineFactCheckerFactory.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
coreServices,
1919
createBackendModule,
2020
} from '@backstage/backend-plugin-api';
21+
import { CatalogClient } from '@backstage/catalog-client';
2122
import { techInsightsFactCheckerFactoryExtensionPoint } from '@backstage-community/plugin-tech-insights-node';
2223
import { JsonRulesEngineFactCheckerFactory } from '../service';
2324

@@ -36,11 +37,14 @@ export const techInsightsModuleJsonRulesEngineFactCheckerFactory =
3637
deps: {
3738
config: coreServices.rootConfig,
3839
logger: coreServices.logger,
40+
discovery: coreServices.discovery,
3941
techInsights: techInsightsFactCheckerFactoryExtensionPoint,
4042
},
41-
async init({ config, logger, techInsights }) {
43+
async init({ config, logger, discovery, techInsights }) {
44+
const catalogClient = new CatalogClient({ discoveryApi: discovery });
4245
const factory = JsonRulesEngineFactCheckerFactory.fromConfig(config, {
4346
logger,
47+
catalogApi: catalogClient,
4448
});
4549
techInsights.setFactCheckerFactory(factory);
4650
},

workspaces/tech-insights/plugins/tech-insights-backend-module-jsonfc/src/service/JsonRulesEngineFactChecker.ts

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import { DefaultCheckRegistry } from './CheckRegistry';
3838
import { readChecksFromConfig } from './config';
3939
import * as validationSchema from './validation-schema.json';
4040
import { LoggerService } from '@backstage/backend-plugin-api';
41+
import { CatalogApi } from '@backstage/catalog-client';
42+
import { Entity } from '@backstage/catalog-model';
43+
import { get } from 'lodash';
4144

4245
const noopEvent = {
4346
type: 'noop',
@@ -53,8 +56,9 @@ export type JsonRulesEngineFactCheckerOptions = {
5356
checks: TechInsightJsonRuleCheck[];
5457
repository: TechInsightsStore;
5558
logger: LoggerService;
56-
checkRegistry?: TechInsightCheckRegistry<any>;
59+
checkRegistry?: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
5760
operators?: Operator[];
61+
catalogApi?: CatalogApi;
5862
};
5963

6064
/**
@@ -71,13 +75,16 @@ export class JsonRulesEngineFactChecker
7175
private readonly logger: LoggerService;
7276
private readonly validationSchema: SchemaObject;
7377
private readonly operators: Operator[];
78+
private readonly catalogApi?: CatalogApi;
7479

7580
constructor(options: JsonRulesEngineFactCheckerOptions) {
76-
const { checks, repository, logger, checkRegistry, operators } = options;
81+
const { checks, repository, logger, checkRegistry, operators, catalogApi } =
82+
options;
7783

7884
this.repository = repository;
7985
this.logger = logger;
8086
this.operators = operators || [];
87+
this.catalogApi = catalogApi;
8188
this.validationSchema = JSON.parse(JSON.stringify(validationSchema));
8289

8390
this.operators.forEach(op => {
@@ -92,6 +99,125 @@ export class JsonRulesEngineFactChecker
9299
new DefaultCheckRegistry<TechInsightJsonRuleCheck>(checks);
93100
}
94101

102+
/**
103+
* Evaluates whether an entity matches the given filter criteria.
104+
* Supports both single filter objects and arrays of filter objects.
105+
* When multiple filter objects are provided, uses OR logic (entity matches if ANY filter matches).
106+
*
107+
* @param entity - The catalog entity to evaluate
108+
* @param filter - Single filter object or array of filter objects to match against
109+
* @returns true if the entity matches the filter criteria, false otherwise
110+
*/
111+
private matchesFilter(
112+
entity: Entity,
113+
filter:
114+
| Record<string, string | symbol | (string | symbol)[]>
115+
| Record<string, string | symbol | (string | symbol)[]>[],
116+
): boolean {
117+
const filters = Array.isArray(filter) ? filter : [filter];
118+
119+
// Match if ANY of the filters match (OR logic between filter objects)
120+
return filters.some(f => this.matchesSingleFilter(entity, f));
121+
}
122+
123+
/**
124+
* Evaluates whether an entity matches a single filter object.
125+
* All key-value pairs in the filter must match (AND logic).
126+
* Supports nested property access using lodash.get (e.g., "metadata.name").
127+
*
128+
* @param entity - The catalog entity to evaluate
129+
* @param filter - Filter object with key-value pairs that must all match
130+
* @returns true if all filter conditions match, false otherwise
131+
*/
132+
private matchesSingleFilter(
133+
entity: Entity,
134+
filter: Record<string, string | symbol | (string | symbol)[]>,
135+
): boolean {
136+
// All conditions in a single filter must match (AND logic within a filter)
137+
return Object.entries(filter).every(([key, value]) => {
138+
const entityValue = get(entity, key);
139+
140+
// Handle undefined/null entity values - if the property doesn't exist on the entity,
141+
// the filter condition cannot be satisfied, so return false
142+
if (entityValue === undefined || entityValue === null) {
143+
this.logger.warn(`Entity property '${key}' is undefined or null`);
144+
return false;
145+
}
146+
147+
// Handle array values (OR logic within array)
148+
// If the filter value is an array, the entity matches if ANY value in the array matches
149+
if (Array.isArray(value)) {
150+
return value.some(v => this.compareValues(entityValue, v));
151+
}
152+
153+
// Single value comparison
154+
return this.compareValues(entityValue, value);
155+
});
156+
}
157+
158+
/**
159+
* Helper method to compare entity property values against filter values.
160+
* Implements case-insensitive string comparison for better user experience,
161+
* as entity kinds, types, and lifecycles are typically case-insensitive.
162+
*
163+
* @param entityValue - The actual value from the entity property
164+
* @param filterValue - The expected value from the filter condition
165+
* @returns true if values match according to the comparison rules, false otherwise
166+
*/
167+
private compareValues(
168+
entityValue: any,
169+
filterValue: string | symbol,
170+
): boolean {
171+
// Handle string comparison case-insensitively for kind, type, lifecycle, etc.
172+
// This provides a better user experience as these values are typically case-insensitive
173+
if (typeof entityValue === 'string' && typeof filterValue === 'string') {
174+
return entityValue.toLowerCase() === filterValue.toLowerCase();
175+
}
176+
177+
// Handle symbol comparison (less common but supported for completeness)
178+
if (typeof filterValue === 'symbol') {
179+
return entityValue === filterValue;
180+
}
181+
182+
// Default to strict equality for all other types (numbers, booleans, etc.)
183+
return entityValue === filterValue;
184+
}
185+
186+
/**
187+
* Fetches an entity from the catalog to enable filter evaluation.
188+
* This is required to access entity metadata (kind, type, lifecycle, etc.) for filtering.
189+
*
190+
* @param entityRef - The entity reference string (e.g., "component:default/my-service")
191+
* @returns The entity object if found and catalogApi is available, undefined otherwise
192+
*/
193+
private async fetchEntityFromCatalog(
194+
entityRef: string,
195+
): Promise<Entity | undefined> {
196+
// If catalogApi wasn't provided in the constructor, filtering cannot be performed
197+
if (!this.catalogApi) {
198+
this.logger.debug(
199+
'CatalogApi not available, skipping entity fetch for filtering',
200+
);
201+
return undefined;
202+
}
203+
204+
try {
205+
const entity = await this.catalogApi.getEntityByRef(entityRef);
206+
207+
if (!entity) {
208+
this.logger.warn(`Entity '${entityRef}' not found in catalog`);
209+
}
210+
211+
return entity;
212+
} catch (e) {
213+
// Log but don't throw - we'll fall back to running all checks without filtering
214+
this.logger.warn(
215+
`Failed to fetch entity ${entityRef} from catalog: ${e}`,
216+
);
217+
return undefined;
218+
}
219+
}
220+
95221
async runChecks(
96222
entity: string,
97223
checks?: string[],
@@ -104,6 +230,51 @@ export class JsonRulesEngineFactChecker
104230
const techInsightChecks = checks
105231
? await this.checkRegistry.getAll(checks)
106232
: await this.checkRegistry.list();
233+
234+
// Identify checks that have filter criteria defined
235+
// Only these checks require entity fetching from the catalog
236+
const checksWithFilters = techInsightChecks.filter(check => check.filter);
237+
238+
// Start with all checks; will be filtered down if entity filtering is applicable
239+
let filteredChecks = techInsightChecks;
240+
241+
// Only fetch entity from catalog if there are checks with filter criteria
242+
// This optimization avoids unnecessary catalog API calls
243+
if (checksWithFilters.length > 0) {
244+
const catalogEntity = await this.fetchEntityFromCatalog(entity);
245+
if (catalogEntity) {
246+
const initialCount = filteredChecks.length;
247+
248+
// Apply filter criteria to determine which checks should run for this entity
249+
// Checks without filters always run; checks with filters only run if they match
250+
filteredChecks = filteredChecks.filter(check => {
251+
// Always include checks that don't have filter criteria
252+
if (!check.filter) {
253+
return true;
254+
}
255+
256+
// Evaluate if the entity matches the check's filter criteria
257+
const matches = this.matchesFilter(catalogEntity, check.filter);
258+
return matches;
259+
});
260+
261+
// Log how many checks were filtered out for observability
262+
// This helps users understand why certain checks didn't run
263+
const skippedCount = initialCount - filteredChecks.length;
264+
if (skippedCount > 0) {
265+
this.logger.info(
266+
`Filtered out ${skippedCount} check(s) based on entity criteria`,
267+
);
268+
}
269+
} else {
270+
// If we couldn't fetch the entity, run all checks as a fallback
271+
// This ensures checks still run even if catalog is unavailable
272+
this.logger.warn(
273+
'Could not fetch entity from catalog for filtering - running all checks',
274+
);
275+
}
276+
}
277+
107278
const factRetrieversIds = techInsightChecks.flatMap(it => it.factIds);
108279
const facts = await this.repository.getLatestFactsByIds(
109280
factRetrieversIds,
@@ -380,6 +551,7 @@ export type JsonRulesEngineFactCheckerFactoryOptions = {
380551
logger: LoggerService;
381552
checkRegistry?: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
382553
operators?: Operator[];
554+
catalogApi?: CatalogApi;
383555
};
384556

385557
/**
@@ -394,6 +566,7 @@ export class JsonRulesEngineFactCheckerFactory {
394566
private readonly logger: LoggerService;
395567
private readonly checkRegistry?: TechInsightCheckRegistry<TechInsightJsonRuleCheck>;
396568
private readonly operators?: Operator[];
569+
private readonly catalogApi?: CatalogApi;
397570

398571
static fromConfig(
399572
config: Config,
@@ -412,6 +585,7 @@ export class JsonRulesEngineFactCheckerFactory {
412585
this.checks = options.checks;
413586
this.checkRegistry = options.checkRegistry;
414587
this.operators = options.operators;
588+
this.catalogApi = options.catalogApi;
415589
}
416590

417591
/**
@@ -426,6 +600,7 @@ export class JsonRulesEngineFactCheckerFactory {
426600
checkRegistry: this.checkRegistry,
427601
repository,
428602
operators: this.operators,
603+
catalogApi: this.catalogApi,
429604
});
430605
}
431606
}

workspaces/tech-insights/plugins/tech-insights-backend-module-jsonfc/src/service/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ function readCheckFromCheckConfig(
165165
const rule = readRuleFromRuleConfig(config.getConfig('rule'));
166166
const links = readLinksForCheck(config.getOptionalConfigArray('links'), opts);
167167

168+
const filter = config
169+
.getOptionalConfig('filter')
170+
?.get<Record<string, string | symbol | (string | symbol)[]>>();
171+
168172
return {
169173
description,
170174
factIds,
@@ -176,6 +180,7 @@ function readCheckFromCheckConfig(
176180
successMetadata,
177181
type,
178182
links,
183+
filter,
179184
};
180185
}
181186

workspaces/tech-insights/plugins/tech-insights-common/src/types/checks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ export interface Check {
8484
* more about the check.
8585
*/
8686
links?: CheckLink[];
87+
88+
/**
89+
* An optional filter to indicate which entities this check should run against.
90+
* If omitted, the check will run against all entities.
91+
*
92+
* Filters can be defined to match entity properties, for example:
93+
* - { kind: 'component' } - Only run check on components
94+
* - { kind: 'component', 'spec.lifecycle': 'production' } - Only run on production components
95+
*
96+
* Multiple filter objects can be provided as an array to match any of the filters.
97+
*/
98+
filter?:
99+
| Record<string, string | symbol | (string | symbol)[]>[]
100+
| Record<string, string | symbol | (string | symbol)[]>;
87101
}
88102

89103
/**

workspaces/tech-insights/plugins/tech-insights-node/src/checks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ export interface TechInsightCheck {
158158
* more about the check.
159159
*/
160160
links?: CheckLink[];
161+
162+
/**
163+
* An optional filter to indicate which entities this check should run against.
164+
* If omitted, the check will run against all entities.
165+
*
166+
* Filters can be defined to match entity properties, for example:
167+
* - { kind: 'component' } - Only run check on components
168+
* - { kind: 'component', 'spec.lifecycle': 'production' } - Only run on production components
169+
*
170+
* Multiple filter objects can be provided as an array to match any of the filters.
171+
*/
172+
filter?:
173+
| Record<string, string | symbol | (string | symbol)[]>[]
174+
| Record<string, string | symbol | (string | symbol)[]>;
161175
}
162176

163177
/**

workspaces/tech-insights/yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2752,6 +2752,7 @@ __metadata:
27522752
"@backstage-community/plugin-tech-insights-node": "workspace:^"
27532753
"@backstage/backend-plugin-api": "npm:^1.3.1"
27542754
"@backstage/backend-test-utils": "npm:^1.5.0"
2755+
"@backstage/catalog-client": "npm:^1.10.0"
27552756
"@backstage/cli": "npm:^0.32.1"
27562757
"@backstage/config": "npm:^1.3.2"
27572758
"@backstage/errors": "npm:^1.2.7"

0 commit comments

Comments
 (0)