Skip to content

Commit 95064a2

Browse files
CLOUDP-294461: [Product Metrics/Observability] Implement Metric Collection workflow (#357)
1 parent 7d774ef commit 95064a2

File tree

5 files changed

+219
-0
lines changed

5 files changed

+219
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@
2121

2222
*.out
2323
**/*ipa-collector-results-combined.log
24+
**/*metric-collection-results.json
25+
**/*spectral-output.txt
26+
**/*spectral-report.xml

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "mongodb-openapi",
33
"description": "MongoDB repository with OpenAPI specification",
4+
"type": "module",
45
"scripts": {
56
"format": "npx prettier . --write",
67
"format-check": "npx prettier . --check",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import path from 'path';
2+
import { fileURLToPath } from 'url';
3+
4+
const dirname = path.dirname(fileURLToPath(import.meta.url));
5+
const rootDir = path.resolve(dirname, '../../../../');
6+
7+
const config = {
8+
defaultOasFilePath: path.join(rootDir, 'openapi', 'v2.json'),
9+
defaultRulesetFilePath: path.join(dirname, '..', 'ipa-spectral.yaml'),
10+
defaultCollectorResultsFilePath: path.join(dirname, 'ipa-collector-results-combined.log'),
11+
defaultOutputsDir: path.join(dirname, 'outputs'),
12+
};
13+
14+
config.defaultMetricCollectionResultsFilePath = path.join(config.defaultOutputsDir, 'metric-collection-results.json');
15+
config.defaultSpectralReportFile = path.join(config.defaultOutputsDir, 'spectral-report.xml');
16+
config.defaultSpectralOutputFile = path.join(config.defaultOutputsDir, 'spectral-output.txt');
17+
18+
export default config;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import spectral from '@stoplight/spectral-core';
2+
import * as fs from 'node:fs';
3+
import { spawnSync } from 'child_process';
4+
import {
5+
loadOpenAPIFile,
6+
extractTeamOwnership,
7+
loadRuleset,
8+
loadCollectorResults,
9+
getSeverityPerRule,
10+
merge,
11+
} from './utils.js';
12+
import config from './config.js';
13+
const { Spectral } = spectral;
14+
15+
async function runMetricCollectionJob(oasFilePath = config.defaultOasFilePath) {
16+
try {
17+
console.log(`Loading OpenAPI file: ${oasFilePath}`);
18+
const oasContent = loadOpenAPIFile(oasFilePath);
19+
20+
console.log('Extracting team ownership data...');
21+
const ownershipData = extractTeamOwnership(oasContent);
22+
23+
console.log('Getting rule severities...');
24+
const spectral = new Spectral();
25+
const ruleset = await loadRuleset(config.defaultRulesetFilePath, spectral);
26+
const ruleSeverityMap = getSeverityPerRule(ruleset);
27+
28+
console.log('Loading collector results...');
29+
const collectorResults = loadCollectorResults(config.defaultCollectorResultsFilePath);
30+
31+
console.log('Merging results...');
32+
const mergedResults = merge(ownershipData, collectorResults, ruleSeverityMap);
33+
34+
console.log('Metric collection job complete.');
35+
return mergedResults;
36+
} catch (error) {
37+
console.error('Error during metric collection:', error.message);
38+
throw error;
39+
}
40+
}
41+
42+
const args = process.argv.slice(2);
43+
const customOasFile = args[0];
44+
45+
if (!fs.existsSync(config.defaultOutputsDir)) {
46+
fs.mkdirSync('outputs');
47+
console.log(`Output directory created successfully`);
48+
}
49+
50+
const result = spawnSync(
51+
'spectral',
52+
[
53+
'lint',
54+
'--ruleset',
55+
config.defaultRulesetFilePath,
56+
'--format',
57+
'stylish',
58+
'--verbose',
59+
'--format',
60+
'junit',
61+
'--output.junit',
62+
config.defaultSpectralReportFile,
63+
config.defaultOasFilePath,
64+
],
65+
{
66+
encoding: 'utf-8',
67+
}
68+
);
69+
70+
if (result.error) {
71+
console.error('Error running Spectral lint:', result.error);
72+
process.exit(1);
73+
}
74+
75+
console.log('Spectral lint completed successfully.');
76+
fs.writeFileSync(config.defaultSpectralOutputFile, result.stdout);
77+
78+
runMetricCollectionJob(customOasFile)
79+
.then((results) => fs.writeFileSync(config.defaultMetricCollectionResultsFilePath, JSON.stringify(results)))
80+
.catch((error) => console.error(error.message));
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import fs from 'node:fs';
2+
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';
3+
import { EntryType } from './collector.js';
4+
5+
export function loadOpenAPIFile(filePath) {
6+
try {
7+
const content = fs.readFileSync(filePath, 'utf8');
8+
return JSON.parse(content);
9+
} catch (error) {
10+
throw new Error(`Failed to load OpenAPI file: ${error.message}`);
11+
}
12+
}
13+
14+
export function getSeverityPerRule(ruleset) {
15+
const rules = ruleset.rules || {};
16+
const map = {};
17+
for (const [name, ruleObject] of Object.entries(rules)) {
18+
map[name] = ruleObject.definition.severity;
19+
}
20+
return map;
21+
}
22+
23+
export async function loadRuleset(rulesetPath, spectral) {
24+
try {
25+
const ruleset = await bundleAndLoadRuleset(rulesetPath, { fs, fetch });
26+
await spectral.setRuleset(ruleset);
27+
return ruleset;
28+
} catch (error) {
29+
throw new Error(`Failed to load ruleset: ${error.message}`);
30+
}
31+
}
32+
33+
export function extractTeamOwnership(oasContent) {
34+
const ownerTeams = {};
35+
const paths = oasContent.paths || {};
36+
37+
for (const [path, pathItem] of Object.entries(paths)) {
38+
for (const [, operation] of Object.entries(pathItem)) {
39+
const ownerTeam = operation['x-xgen-owner-team'];
40+
41+
if (ownerTeam) {
42+
if (!ownerTeams[path]) {
43+
ownerTeams[path] = ownerTeam;
44+
} else if (ownerTeams[path] !== ownerTeam) {
45+
console.warn(`Conflict on path ${path}: ${ownerTeams[path]} vs ${ownerTeam}`);
46+
}
47+
}
48+
}
49+
}
50+
51+
return ownerTeams;
52+
}
53+
54+
export function loadCollectorResults(collectorResultsFilePath) {
55+
try {
56+
const content = fs.readFileSync(collectorResultsFilePath, 'utf8');
57+
const contentParsed = JSON.parse(content);
58+
return {
59+
[EntryType.VIOLATION]: contentParsed[EntryType.VIOLATION],
60+
[EntryType.ADOPTION]: contentParsed[EntryType.ADOPTION],
61+
[EntryType.EXCEPTION]: contentParsed[EntryType.EXCEPTION],
62+
};
63+
} catch (error) {
64+
throw new Error(`Failed to load Collector Results file: ${error.message}`);
65+
}
66+
}
67+
68+
function getIPAFromIPARule(ipaRule) {
69+
const pattern = /IPA-\d{3}/;
70+
const match = ipaRule.match(pattern);
71+
if (match) {
72+
return match[0];
73+
}
74+
}
75+
76+
export function merge(ownershipData, collectorResults, ruleSeverityMap) {
77+
const results = [];
78+
79+
function addEntry(entryType, adoptionStatus) {
80+
for (const entry of collectorResults[entryType]) {
81+
const existing = results.find(
82+
(result) => result.component_id === entry.componentId && result.ipa_rule === entry.ruleName
83+
);
84+
85+
if (existing) {
86+
console.warn('Duplicate entries found', existing);
87+
continue;
88+
}
89+
90+
let ownerTeam = null;
91+
if (entry.componentId.startsWith('paths')) {
92+
const pathParts = entry.componentId.split('.');
93+
if (pathParts.length === 2) {
94+
const path = pathParts[1];
95+
ownerTeam = ownershipData[path];
96+
}
97+
}
98+
99+
results.push({
100+
component_id: entry.componentId,
101+
ipa_rule: entry.ruleName,
102+
ipa: getIPAFromIPARule(entry.ruleName),
103+
severity_level: ruleSeverityMap[entry.ruleName],
104+
adoption_status: adoptionStatus,
105+
exception_reason: entryType === EntryType.EXCEPTION ? entry.exceptionReason : null,
106+
owner_team: ownerTeam,
107+
timestamp: new Date().toISOString(),
108+
});
109+
}
110+
}
111+
112+
addEntry(EntryType.VIOLATION, 'violated');
113+
addEntry(EntryType.ADOPTION, 'adopted');
114+
addEntry(EntryType.EXCEPTION, 'exempted');
115+
116+
return results;
117+
}

0 commit comments

Comments
 (0)