From 7617a16d000a86e48ec897a6d9dba0492275c1d5 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 30 Jan 2025 13:19:41 -0500 Subject: [PATCH 1/2] Refactored scripts - Added a unit test to aid refaxtoring. - Added an override for kibana root, which we'll need for CI - Added a dry run option and removed test script --- packages/eui-usage-analytics/README.md | 22 +- packages/eui-usage-analytics/index.js | 55 +++-- packages/eui-usage-analytics/package.json | 9 +- packages/eui-usage-analytics/scan.js | 274 ++++++++++++++-------- packages/eui-usage-analytics/scan.test.js | 63 +++++ packages/eui-usage-analytics/test.js | 19 -- yarn.lock | 4 +- 7 files changed, 315 insertions(+), 131 deletions(-) create mode 100644 packages/eui-usage-analytics/scan.test.js delete mode 100644 packages/eui-usage-analytics/test.js diff --git a/packages/eui-usage-analytics/README.md b/packages/eui-usage-analytics/README.md index 48c5fd4c55f..e34f6418049 100644 --- a/packages/eui-usage-analytics/README.md +++ b/packages/eui-usage-analytics/README.md @@ -27,9 +27,29 @@ This script must be run from this directory. ``` CLOUD_ID_SECRET=****** AUTH_APIKEY_SECRET=****** node index.js + +# Repository roots can be overridden as well. Check --help for more info +CLOUD_ID_SECRET=****** AUTH_APIKEY_SECRET=****** node index.js --kibana-root=/path/to/kibana +``` + +To do a dry run for testing: +``` +node index.js --dry +``` + +## If you ever need to delete a bad scan: +``` +POST /eui_components/_delete_by_query +{ + "query": { + "term": { + "@timestamp": "2024-07-22T20:39:38.992Z" + } + } +} ``` -## Schema +## Record Schema This script will store data in an Elastic index named `eui_components`. diff --git a/packages/eui-usage-analytics/index.js b/packages/eui-usage-analytics/index.js index cd5ee54277e..df3954be986 100644 --- a/packages/eui-usage-analytics/index.js +++ b/packages/eui-usage-analytics/index.js @@ -7,33 +7,54 @@ */ const { scan } = require('./scan'); +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); const { Client } = require('@elastic/elasticsearch'); -if (!process.env.CLOUD_ID_SECRET || !process.env.AUTH_APIKEY_SECRET) { - console.error( - 'CLOUD_ID_SECRET and AUTH_APIKEY_SECRET environment variables must be set before running this script.' - ); - process.exit(1); -} +const argv = yargs(hideBin(process.argv)) + .option('kibana-root', { + type: 'string', + describe: 'Path to Kibana repo root', + }) + .option('dry', { + type: 'boolean', + describe: 'Path to Kibana repo root', + }) + .help().argv; + +let client; -const client = new Client({ - cloud: { - id: process.env.CLOUD_ID_SECRET, - }, - auth: { - apiKey: process.env.AUTH_APIKEY_SECRET, - }, -}); +if (!argv['dry']) { + if (!process.env.CLOUD_ID_SECRET || !process.env.AUTH_APIKEY_SECRET) { + console.error( + 'CLOUD_ID_SECRET and AUTH_APIKEY_SECRET environment variables must be set before running this script.' + ); + process.exit(1); + } + + client = new Client({ + cloud: { + id: process.env.CLOUD_ID_SECRET, + }, + auth: { + apiKey: process.env.AUTH_APIKEY_SECRET, + }, + }); +} const run = async () => { - const result = await scan(); + const result = await scan({ kibanaRoot: argv['kibana-root'] }); const operations = result.flatMap((doc) => [ { index: { _index: 'eui_components' } }, doc, ]); - const response = await client.bulk({ refresh: true, operations }); - console.log(response); + if (client) { + const response = await client.bulk({ refresh: true, operations }); + console.log(response); + } else { + console.log(result); + } }; run().catch((e) => console.error(e)); diff --git a/packages/eui-usage-analytics/package.json b/packages/eui-usage-analytics/package.json index 976df86b7b4..1182b446c99 100644 --- a/packages/eui-usage-analytics/package.json +++ b/packages/eui-usage-analytics/package.json @@ -7,6 +7,13 @@ "@elastic/elasticsearch": "^8.14.0", "codeowners": "^5.1.1", "escodegen-wallaby": "^1.6.44", - "react-scanner": "^1.1.0" + "react-scanner": "^1.1.0", + "yargs": "^17.7.2" + }, + "scripts": { + "test": "jest" + }, + "devDependencies": { + "jest": "^29.7.0" } } diff --git a/packages/eui-usage-analytics/scan.js b/packages/eui-usage-analytics/scan.js index 9ba0219bd23..040ad10f05b 100644 --- a/packages/eui-usage-analytics/scan.js +++ b/packages/eui-usage-analytics/scan.js @@ -10,25 +10,107 @@ const scanner = require('react-scanner'); const escodegen = require('escodegen-wallaby'); const Codeowners = require('codeowners'); -const codeowners = new Codeowners('../../../kibana'); const path = require('path'); const cwd = path.resolve(__dirname); -// NOTE: Do not add private repos to this list. If we plan to add private repos, we should do so via configuration rather than source. -const repos = { - kibana: { - linkPrefix: 'https://github.com/elastic/kibana/blob/main/', - crawlFrom: [ - /* - * Scanning the entirety of Kibana could lead to many false negatives and be. - * inefficient. These 3 crawl roots may not be 100% comprehensive, but they should cover - * most code usages - */ - '../../../kibana/src', - '../../../kibana/x-pack', - '../../../kibana/packages', - ], - }, +const getRepoConfig = ({ kibanaRoot = '../../../kibana' }) => { + // NOTE: Do not add private repos to this list. If we plan to add private repos, we should do so via configuration rather than source. + return { + kibana: { + linkPrefix: 'https://github.com/elastic/kibana/blob/main/', + codeownersPath: '', + repoRoot: kibanaRoot, + crawlFrom: [ + /* + * Scanning the entirety of Kibana could lead to many false negatives and be. + * inefficient. These 3 crawl roots may not be 100% comprehensive, but they should cover + * most code usages + */ + `${kibanaRoot}/src`, + `${kibanaRoot}/x-pack`, + `${kibanaRoot}/packages`, + ], + }, + }; +}; + +/** + * When props are passed expressions as values, they're not serialized. + * + * For example: + * (somethingIstrue) ? 'primary' : 'secondary'} /> + * + * The prop value that gets reported to our Dashboard would be '(ArrowFunctionExpression)' + * + * Sometimes this is useful, and sometimes it's not. + * + * [A case where it IS useful] + * + * style + * Default: (ObjectExpression) + * Serialized: { height: '100%', width: '100%' } + * + * Since 'style' almost exclusively receives expressions, it's universally more helpful + * to see the serialized versions. Otherwise, when we look at the Dashboard we'd just + * see all the values reported as '(ObjectExpression)'. + * + * [A case where it is IS NOT useful] + * + * 'color': + * # Most values are literals + * Default: 'primary' + * Serialized: 'primary' + * + * # Some values are expressions + * Default: (ConditionalExpression) + * Serialized: isExpanded ? 'danger' : 'primary' + * + * Default: euiTheme.colors.primaryText: + * Serialized: (MemberExpression) + * + * For a case like 'color', they're typically passed as literals, and there's typically + * a finite list of options. By *not* serializing the expressions, bucket everything that's + * not a literal into values like '(ConditionalExpression)' + * + * This means we get cleaner counts on the Dashboard, if you can imagine the following on a + * Kibana Dashboard: + * + * color: + * primary: 500 + * secondary: 300 + * (Expression): 200 + * + * The tradeoff is that you lose some detail in certain usages, but the benefit is that you get + * better counts. + * + * In either case, this is the function where we can figure whether or not we serialize expressions + * for various props. + * + * [Note] This could be refined even more as we go: + * - Only serialize props on specific components (so add a conditional for component name) + * - Only serialize specific types of expressions + * + * Reference: https://github.com/moroshko/react-scanner?tab=readme-ov-file#customizing-prop-values-treatment + **/ +const PROPS_TO_SERIALIZE = ['css', 'style']; +const propValueProcessor = ({ node, propName, defaultGetPropValue }) => { + if (PROPS_TO_SERIALIZE.includes(propName)) { + if (node.type === 'JSXExpressionContainer') { + try { + return escodegen.generate(node.expression); + } catch { + return defaultGetPropValue(node); + } + } else { + try { + return escodegen.generate(node); + } catch { + return defaultGetPropValue(node); + } + } + } else { + return defaultGetPropValue(node); + } }; const scannerConfig = { @@ -46,92 +128,100 @@ const scannerConfig = { */ processors: ['raw-report'], crawlFrom: './', - getPropValue: ({ node, propName, defaultGetPropValue }) => { - /** - * Certain complex types of prop values don't get seriealized, so you just - * see "(ArrowFunctionExpression)", etc. as the prop value. You can manually define - * serializers here. The serializer below lets us see values like - * `style::{ fontWeight: 'bold' }` instead of `JSXExpressionContainer` in data. - * - * This could be expanded further. - **/ - if (propName === 'css' || propName === 'style') { - if (node.type === 'JSXExpressionContainer') { - try { - return escodegen.generate(node.expression); - } catch { - return defaultGetPropValue(node); - } - } else { - try { - return escodegen.generate(node); - } catch { - return defaultGetPropValue(node); - } - } - } else { - return defaultGetPropValue(node); - } - }, + getPropValue: propValueProcessor, }; -const scan = async () => { +/** + * When root == kibana + * + * Input: /User/local/kibana/some/sub/dir + * Output: some/sub/dir + * + */ +const parseRelativePathFromAbsolute = (root, absolutePath) => { + const regex = new RegExp(`\/${root}\/(.*)$`); + return regex.exec(absolutePath)[1]; +}; + +const buildRecordForElasticsearch = ( + instance, + linkPrefix, + repo, + codeowners, + time, + componentName +) => { + if (instance.location?.file) { + const relativePath = parseRelativePathFromAbsolute( + repo, + instance.location.file + ); + relativePathWithRoot = `/${repo}/${relativePath}`; + sourceLocation = `${linkPrefix}${relativePath}#L${instance.location.start.line}`; + owners = codeowners.getOwner(relativePath); + } + + return { + '@timestamp': time, + scanDate: time, + component: componentName, + codeOwners: owners, + moduleName: instance.importInfo?.moduleName, + props: Object.entries(instance.props).map(([k, v]) => ({ + propName: k, + propValue: v, + })), + props_combined: Object.entries(instance.props).map( + ([k, v]) => `${k}::${v}` + ), + fileName: relativePathWithRoot, + sourceLocation, + lineNumber: instance.location?.start?.line, + lineColumn: instance.location?.start?.column, + repository: repo, + }; +}; + +const scan = async (config = {}) => { let time = new Date(); let output = []; + const repos = getRepoConfig(config); await Promise.all( - Object.entries(repos).map(async ([repo, { crawlFrom, linkPrefix }]) => { - await Promise.all( - crawlFrom.map(async (kibanaCrawlDirs) => { - let newOutput = await scanner.run({ - ...scannerConfig, - crawlFrom: kibanaCrawlDirs, - }); - - newOutput = Object.entries(newOutput).flatMap( - ([componentName, value]) => { - return value.instances?.map((instance) => { - let fileName; - let sourceLocation; - let owners = []; + Object.entries(repos).map( + async ([repo, { crawlFrom, linkPrefix, repoRoot }]) => { + const codeowners = new Codeowners(repoRoot); + await Promise.all( + crawlFrom.map(async (crawlDirs) => { + let newOutput = await scanner.run({ + ...scannerConfig, + crawlFrom: crawlDirs, + }); - let regex = /\/kibana\/(.*)$/; - if (instance.location?.file) { - const result = regex.exec(instance.location.file); - fileName = result[0]; - sourceLocation = `${linkPrefix}${result[1]}#L${instance.location.start.line}`; - owners = codeowners.getOwner(result[1]); - } - - return { - '@timestamp': time, - scanDate: time, - component: componentName, - codeOwners: owners, - moduleName: instance.importInfo?.moduleName, - props: Object.entries(instance.props).map(([k, v]) => ({ - propName: k, - propValue: v, - })), - props_combined: Object.entries(instance.props).map( - ([k, v]) => `${k}::${v}` - ), - fileName, - sourceLocation, - lineNumber: instance.location?.start?.line, - lineColumn: instance.location?.start?.column, - repository: repo, - }; - }); - } - ); - output = output.concat(newOutput); - }) - ); - }) + newOutput = Object.entries(newOutput).flatMap( + ([componentName, value]) => { + return value.instances?.map((instance) => { + return buildRecordForElasticsearch( + instance, + linkPrefix, + repo, + codeowners, + time, + componentName + ); + }); + } + ); + output = output.concat(newOutput); + }) + ); + } + ) ); return output; }; exports.scan = scan; +exports.buildRecordForElasticsearch = buildRecordForElasticsearch; +exports.propValueProcessor = propValueProcessor; diff --git a/packages/eui-usage-analytics/scan.test.js b/packages/eui-usage-analytics/scan.test.js new file mode 100644 index 00000000000..0f6a08f4617 --- /dev/null +++ b/packages/eui-usage-analytics/scan.test.js @@ -0,0 +1,63 @@ +const { buildRecordForElasticsearch, propValueProcessor } = require('./scan'); + +describe('buildRecordForElasticsearch', () => { + it('can build a record to send to Elasticsearch', () => { + const before = { + importInfo: { + imported: 'EuiButton', + local: 'EuiButton', + moduleName: '@elastic/eui', + importType: 'ImportSpecifier', + }, + props: { + color: 'danger', + disabled: '(Identifier)', + isLoading: '(BinaryExpression)', + onClick: '(ArrowFunctionExpression)', + }, + propsSpread: false, + location: { + file: '/Users/jasonstoltzfus/workspace/kibana/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/no_connector_record.tsx', + start: { line: 63, column: 11 }, + }, + }; + + const after = { + '@timestamp': '2025-01-29T21:02:05.384Z', + scanDate: '2025-01-29T21:02:05.384Z', + component: 'EuiButton', + codeOwners: ['@elastic/search-kibana'], + moduleName: '@elastic/eui', + props: [ + { propName: 'color', propValue: 'danger' }, + { propName: 'disabled', propValue: '(Identifier)' }, + { propName: 'isLoading', propValue: '(BinaryExpression)' }, + { propName: 'onClick', propValue: '(ArrowFunctionExpression)' }, + ], + props_combined: [ + 'color::danger', + 'disabled::(Identifier)', + 'isLoading::(BinaryExpression)', + 'onClick::(ArrowFunctionExpression)', + ], + fileName: + '/kibana/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/no_connector_record.tsx', + sourceLocation: + 'https://github.com/elastic/kibana/blob/main/x-pack/solutions/search/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/no_connector_record.tsx#L63', + lineNumber: 63, + lineColumn: 11, + repository: 'kibana', + }; + + expect( + buildRecordForElasticsearch( + before, + 'https://github.com/elastic/kibana/blob/main/', + 'kibana', + { getOwner: () => ['@elastic/search-kibana'] }, + '2025-01-29T21:02:05.384Z', + 'EuiButton' + ) + ).toEqual(after); + }); +}); diff --git a/packages/eui-usage-analytics/test.js b/packages/eui-usage-analytics/test.js deleted file mode 100644 index 2864180bb85..00000000000 --- a/packages/eui-usage-analytics/test.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const Codeowners = require('codeowners'); -const temp = new Codeowners("../../../kibana"); - -const { scan } = require('./scan'); - -const runScan = async () => { - const scanResult = await scan(); - console.log(scanResult); -}; - -runScan(); diff --git a/yarn.lock b/yarn.lock index 017d126fa92..41707312a17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4656,7 +4656,9 @@ __metadata: "@elastic/elasticsearch": "npm:^8.14.0" codeowners: "npm:^5.1.1" escodegen-wallaby: "npm:^1.6.44" + jest: "npm:^29.7.0" react-scanner: "npm:^1.1.0" + yargs: "npm:^17.7.2" languageName: unknown linkType: soft @@ -37028,7 +37030,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1, yargs@npm:^17.5.1": +"yargs@npm:^17.3.1, yargs@npm:^17.5.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From 924d95780c70a9d3fa82c2a02c201f67f2280361 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 30 Jan 2025 14:02:34 -0500 Subject: [PATCH 2/2] Added cloud --- packages/eui-usage-analytics/index.js | 5 ++++- packages/eui-usage-analytics/scan.js | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/eui-usage-analytics/index.js b/packages/eui-usage-analytics/index.js index df3954be986..9f20c53f576 100644 --- a/packages/eui-usage-analytics/index.js +++ b/packages/eui-usage-analytics/index.js @@ -44,7 +44,10 @@ if (!argv['dry']) { } const run = async () => { - const result = await scan({ kibanaRoot: argv['kibana-root'] }); + const result = await scan({ + kibanaRoot: argv['kibana-root'], + cloudRoot: argv['cloud-root'], + }); const operations = result.flatMap((doc) => [ { index: { _index: 'eui_components' } }, doc, diff --git a/packages/eui-usage-analytics/scan.js b/packages/eui-usage-analytics/scan.js index 040ad10f05b..d4dd152734f 100644 --- a/packages/eui-usage-analytics/scan.js +++ b/packages/eui-usage-analytics/scan.js @@ -13,7 +13,10 @@ const Codeowners = require('codeowners'); const path = require('path'); const cwd = path.resolve(__dirname); -const getRepoConfig = ({ kibanaRoot = '../../../kibana' }) => { +const getRepoConfig = ({ + kibanaRoot = '../../../kibana', + cloudRoot = '../../../cloud', +}) => { // NOTE: Do not add private repos to this list. If we plan to add private repos, we should do so via configuration rather than source. return { kibana: { @@ -31,6 +34,12 @@ const getRepoConfig = ({ kibanaRoot = '../../../kibana' }) => { `${kibanaRoot}/packages`, ], }, + cloud: { + linkPrefix: 'https://github.com/elastic/cloud/blob/master/', + codeownersPath: '', + repoRoot: cloudRoot, + crawlFrom: [`${cloudRoot}/cloud-ui/apps/monolith`], + }, }; };