[7.x] [kbn/optimizer][ci-stats] ship metrics separate from build (#90482) (#90659)

Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Spencer 2021-02-08 15:40:11 -08:00 committed by GitHub
parent 4bf2f618a4
commit d3c37cde22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 519 additions and 354 deletions

View file

@ -555,6 +555,7 @@
"@types/webpack": "^4.41.3",
"@types/webpack-env": "^1.15.3",
"@types/webpack-merge": "^4.1.5",
"@types/webpack-sources": "^0.1.4",
"@types/write-pkg": "^3.1.0",
"@types/xml-crypto": "^1.4.1",
"@types/xml2js": "^0.4.5",
@ -840,6 +841,7 @@
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2",
"webpack-sources": "^1.4.1",
"write-pkg": "^4.0.0",
"xml-crypto": "^2.0.0",
"xmlbuilder": "13.0.2",

View file

@ -7,3 +7,4 @@
*/
export * from './ci_stats_reporter';
export * from './ship_ci_stats_cli';

View file

@ -0,0 +1,48 @@
/*
* 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.
*/
import Path from 'path';
import Fs from 'fs';
import { CiStatsReporter } from './ci_stats_reporter';
import { run, createFlagError } from '../run';
export function shipCiStatsCli() {
run(
async ({ log, flags }) => {
let metricPaths = flags.metrics;
if (typeof metricPaths === 'string') {
metricPaths = [metricPaths];
} else if (!Array.isArray(metricPaths) || !metricPaths.every((p) => typeof p === 'string')) {
throw createFlagError('expected --metrics to be a string');
}
const reporter = CiStatsReporter.fromEnv(log);
for (const path of metricPaths) {
// resolve path from CLI relative to CWD
const abs = Path.resolve(path);
const json = Fs.readFileSync(abs, 'utf8');
await reporter.metrics(JSON.parse(json));
log.success('shipped metrics from', path);
}
},
{
description: 'ship ci-stats which have been written to files',
usage: `node scripts/ship_ci_stats`,
log: {
defaultLevel: 'debug',
},
flags: {
string: ['metrics'],
help: `
--metrics [path] A path to a JSON file that includes metrics which should be sent. Multiple instances supported
`,
},
}
);
}

View file

@ -12,11 +12,10 @@ import Path from 'path';
import { REPO_ROOT } from '@kbn/utils';
import { lastValueFrom } from '@kbn/std';
import { run, createFlagError, CiStatsReporter } from '@kbn/dev-utils';
import { run, createFlagError } from '@kbn/dev-utils';
import { logOptimizerState } from './log_optimizer_state';
import { OptimizerConfig } from './optimizer';
import { reportOptimizerStats } from './report_optimizer_stats';
import { runOptimizer } from './run_optimizer';
import { validateLimitsForAllBundles, updateBundleLimits } from './limits';
@ -120,17 +119,7 @@ run(
return;
}
let update$ = runOptimizer(config);
if (reportStats) {
const reporter = CiStatsReporter.fromEnv(log);
if (!reporter.isEnabled()) {
log.warning('Unable to initialize CiStatsReporter from env');
}
update$ = update$.pipe(reportOptimizerStats(reporter, config, log));
}
const update$ = runOptimizer(config);
await lastValueFrom(update$.pipe(logOptimizerState(log, config)));
@ -153,7 +142,6 @@ run(
'cache',
'profile',
'inspect-workers',
'report-stats',
'validate-limits',
'update-limits',
],
@ -179,7 +167,6 @@ run(
--dist create bundles that are suitable for inclusion in the Kibana distributable, enabled when running with --update-limits
--scan-dir add a directory to the list of directories scanned for plugins (specify as many times as necessary)
--no-inspect-workers when inspecting the parent process, don't inspect the workers
--report-stats attempt to report stats about this execution of the build to the kibana-ci-stats service using this name
--validate-limits validate the limits.yml config to ensure that there are limits defined for every bundle
--update-limits run a build and rewrite the limits file to include the current bundle sizes +5kb
`,

View file

@ -42,6 +42,7 @@ it('creates cache keys', () => {
"id": "bar",
"manifestPath": undefined,
"outputDir": "/foo/bar/target",
"pageLoadAssetSizeLimit": undefined,
"publicDirNames": Array [
"public",
],
@ -79,6 +80,7 @@ it('parses bundles from JSON specs', () => {
"id": "bar",
"manifestPath": undefined,
"outputDir": "/foo/bar/target",
"pageLoadAssetSizeLimit": undefined,
"publicDirNames": Array [
"public",
],

View file

@ -36,6 +36,8 @@ export interface BundleSpec {
readonly banner?: string;
/** Absolute path to a kibana.json manifest file, if omitted we assume there are not dependenices */
readonly manifestPath?: string;
/** Maximum allowed page load asset size for the bundles page load asset */
readonly pageLoadAssetSizeLimit?: number;
}
export class Bundle {
@ -63,6 +65,8 @@ export class Bundle {
* Every bundle mentioned in the `requiredBundles` must be built together.
*/
public readonly manifestPath: BundleSpec['manifestPath'];
/** Maximum allowed page load asset size for the bundles page load asset */
public readonly pageLoadAssetSizeLimit: BundleSpec['pageLoadAssetSizeLimit'];
public readonly cache: BundleCache;
@ -75,8 +79,9 @@ export class Bundle {
this.outputDir = spec.outputDir;
this.manifestPath = spec.manifestPath;
this.banner = spec.banner;
this.pageLoadAssetSizeLimit = spec.pageLoadAssetSizeLimit;
this.cache = new BundleCache(Path.resolve(this.outputDir, '.kbn-optimizer-cache'));
this.cache = new BundleCache(this.outputDir);
}
/**
@ -107,6 +112,7 @@ export class Bundle {
outputDir: this.outputDir,
manifestPath: this.manifestPath,
banner: this.banner,
pageLoadAssetSizeLimit: this.pageLoadAssetSizeLimit,
};
}
@ -222,6 +228,13 @@ export function parseBundles(json: string) {
}
}
const { pageLoadAssetSizeLimit } = spec;
if (pageLoadAssetSizeLimit !== undefined) {
if (!(typeof pageLoadAssetSizeLimit === 'number')) {
throw new Error('`bundles[]` must have a numeric `pageLoadAssetSizeLimit` property');
}
}
return new Bundle({
type,
id,
@ -231,6 +244,7 @@ export function parseBundles(json: string) {
outputDir,
banner,
manifestPath,
pageLoadAssetSizeLimit,
});
}
);

View file

@ -25,12 +25,12 @@ beforeEach(() => {
});
it(`doesn't complain if files are not on disk`, () => {
const cache = new BundleCache('/foo/bar.json');
const cache = new BundleCache('/foo');
expect(cache.get()).toEqual({});
});
it(`updates files on disk when calling set()`, () => {
const cache = new BundleCache('/foo/bar.json');
const cache = new BundleCache('/foo');
cache.set(SOME_STATE);
expect(mockReadFileSync).not.toHaveBeenCalled();
expect(mockMkdirSync.mock.calls).toMatchInlineSnapshot(`
@ -46,7 +46,7 @@ it(`updates files on disk when calling set()`, () => {
expect(mockWriteFileSync.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/foo/bar.json",
"/foo/.kbn-optimizer-cache",
"{
\\"cacheKey\\": \\"abc\\",
\\"files\\": [
@ -61,7 +61,7 @@ it(`updates files on disk when calling set()`, () => {
});
it(`serves updated state from memory`, () => {
const cache = new BundleCache('/foo/bar.json');
const cache = new BundleCache('/foo');
cache.set(SOME_STATE);
jest.clearAllMocks();
@ -72,7 +72,7 @@ it(`serves updated state from memory`, () => {
});
it('reads state from disk on get() after refresh()', () => {
const cache = new BundleCache('/foo/bar.json');
const cache = new BundleCache('/foo');
cache.set(SOME_STATE);
cache.refresh();
jest.clearAllMocks();
@ -83,7 +83,7 @@ it('reads state from disk on get() after refresh()', () => {
expect(mockReadFileSync.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/foo/bar.json",
"/foo/.kbn-optimizer-cache",
"utf8",
],
]
@ -91,7 +91,7 @@ it('reads state from disk on get() after refresh()', () => {
});
it('provides accessors to specific state properties', () => {
const cache = new BundleCache('/foo/bar.json');
const cache = new BundleCache('/foo');
expect(cache.getModuleCount()).toBe(undefined);
expect(cache.getReferencedFiles()).toEqual(undefined);

View file

@ -9,6 +9,9 @@
import Fs from 'fs';
import Path from 'path';
import webpack from 'webpack';
import { RawSource } from 'webpack-sources';
export interface State {
optimizerCacheKey?: unknown;
cacheKey?: unknown;
@ -20,13 +23,17 @@ export interface State {
const DEFAULT_STATE: State = {};
const DEFAULT_STATE_JSON = JSON.stringify(DEFAULT_STATE);
const CACHE_FILENAME = '.kbn-optimizer-cache';
/**
* Helper to read and update metadata for bundles.
*/
export class BundleCache {
private state: State | undefined = undefined;
constructor(private readonly path: string | false) {}
private readonly path: string | false;
constructor(outputDir: string | false) {
this.path = outputDir === false ? false : Path.resolve(outputDir, CACHE_FILENAME);
}
refresh() {
this.state = undefined;
@ -63,6 +70,7 @@ export class BundleCache {
set(updated: State) {
this.state = updated;
if (this.path) {
const directory = Path.dirname(this.path);
Fs.mkdirSync(directory, { recursive: true });
@ -107,4 +115,16 @@ export class BundleCache {
}
}
}
public writeWebpackAsset(compilation: webpack.compilation.Compilation) {
if (!this.path) {
return;
}
const source = new RawSource(JSON.stringify(this.state, null, 2));
// see https://github.com/jantimon/html-webpack-plugin/blob/33d69f49e6e9787796402715d1b9cd59f80b628f/index.js#L266
// @ts-expect-error undocumented, used to add assets to the output
compilation.emitAsset(CACHE_FILENAME, source);
}
}

View file

@ -9,6 +9,5 @@
export { OptimizerConfig } from './optimizer';
export * from './run_optimizer';
export * from './log_optimizer_state';
export * from './report_optimizer_stats';
export * from './node';
export * from './limits';

File diff suppressed because one or more lines are too long

View file

@ -16,13 +16,7 @@ import del from 'del';
import { tap, filter } from 'rxjs/operators';
import { REPO_ROOT } from '@kbn/utils';
import { ToolingLog } from '@kbn/dev-utils';
import {
runOptimizer,
OptimizerConfig,
OptimizerUpdate,
logOptimizerState,
readLimits,
} from '@kbn/optimizer';
import { runOptimizer, OptimizerConfig, OptimizerUpdate, logOptimizerState } from '@kbn/optimizer';
import { allValuesFrom } from '../common';
@ -69,9 +63,6 @@ it('builds expected bundles, saves bundle counts to metadata', async () => {
dist: false,
});
expect(config.limits).toEqual(readLimits());
(config as any).limits = '<Limits>';
expect(config).toMatchSnapshot('OptimizerConfig');
const msgs = await allValuesFrom(
@ -231,6 +222,10 @@ it('prepares assets for distribution', async () => {
await allValuesFrom(runOptimizer(config).pipe(logOptimizerState(log, config)));
expect(
Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/metrics.json'), 'utf8')
).toMatchSnapshot('metrics.json');
expectFileMatchesSnapshotWithCompression('plugins/foo/target/public/foo.plugin.js', 'foo bundle');
expectFileMatchesSnapshotWithCompression(
'plugins/foo/target/public/foo.chunk.1.js',

View file

@ -7,12 +7,13 @@
*/
import Fs from 'fs';
import Path from 'path';
import dedent from 'dedent';
import Yaml from 'js-yaml';
import { createFailError, ToolingLog } from '@kbn/dev-utils';
import { createFailError, ToolingLog, CiStatsMetrics } from '@kbn/dev-utils';
import { OptimizerConfig, getMetrics, Limits } from './optimizer';
import { OptimizerConfig, Limits } from './optimizer';
const LIMITS_PATH = require.resolve('../limits.yml');
const DEFAULT_BUDGET = 15000;
@ -33,7 +34,7 @@ export function readLimits(): Limits {
}
export function validateLimitsForAllBundles(log: ToolingLog, config: OptimizerConfig) {
const limitBundleIds = Object.keys(config.limits.pageLoadAssetSize || {});
const limitBundleIds = Object.keys(readLimits().pageLoadAssetSize || {});
const configBundleIds = config.bundles.map((b) => b.id);
const missingBundleIds = diff(configBundleIds, limitBundleIds);
@ -75,15 +76,21 @@ interface UpdateBundleLimitsOptions {
}
export function updateBundleLimits({ log, config, dropMissing }: UpdateBundleLimitsOptions) {
const metrics = getMetrics(log, config);
const limits = readLimits();
const metrics: CiStatsMetrics = config.bundles
.map((bundle) =>
JSON.parse(Fs.readFileSync(Path.resolve(bundle.outputDir, 'metrics.json'), 'utf-8'))
)
.flat()
.sort((a, b) => a.id.localeCompare(b.id));
const pageLoadAssetSize: NonNullable<Limits['pageLoadAssetSize']> = dropMissing
? {}
: config.limits.pageLoadAssetSize ?? {};
: limits.pageLoadAssetSize ?? {};
for (const metric of metrics.sort((a, b) => a.id.localeCompare(b.id))) {
for (const metric of metrics) {
if (metric.group === 'page load bundle size') {
const existingLimit = config.limits.pageLoadAssetSize?.[metric.id];
const existingLimit = limits.pageLoadAssetSize?.[metric.id];
pageLoadAssetSize[metric.id] =
existingLimit != null && existingLimit >= metric.value
? existingLimit

View file

@ -1,118 +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.
*/
import Fs from 'fs';
import Path from 'path';
import { ToolingLog, CiStatsMetrics } from '@kbn/dev-utils';
import { OptimizerConfig } from './optimizer_config';
const flatten = <T>(arr: Array<T | T[]>): T[] =>
arr.reduce((acc: T[], item) => acc.concat(item), []);
interface Entry {
relPath: string;
stats: Fs.Stats;
}
const IGNORED_EXTNAME = ['.map', '.br', '.gz'];
const getFiles = (dir: string, parent?: string) =>
flatten(
Fs.readdirSync(dir).map((name): Entry | Entry[] => {
const absPath = Path.join(dir, name);
const relPath = parent ? Path.join(parent, name) : name;
const stats = Fs.statSync(absPath);
if (stats.isDirectory()) {
return getFiles(absPath, relPath);
}
return {
relPath,
stats,
};
})
).filter((file) => {
const filename = Path.basename(file.relPath);
if (filename.startsWith('.')) {
return false;
}
const ext = Path.extname(filename);
if (IGNORED_EXTNAME.includes(ext)) {
return false;
}
return true;
});
export function getMetrics(log: ToolingLog, config: OptimizerConfig) {
return flatten(
config.bundles.map((bundle) => {
// make the cache read from the cache file since it was likely updated by the worker
bundle.cache.refresh();
const outputFiles = getFiles(bundle.outputDir);
const entryName = `${bundle.id}.${bundle.type}.js`;
const entry = outputFiles.find((f) => f.relPath === entryName);
if (!entry) {
throw new Error(
`Unable to find bundle entry named [${entryName}] in [${bundle.outputDir}]`
);
}
const chunkPrefix = `${bundle.id}.chunk.`;
const asyncChunks = outputFiles.filter((f) => f.relPath.startsWith(chunkPrefix));
const miscFiles = outputFiles.filter((f) => f !== entry && !asyncChunks.includes(f));
if (asyncChunks.length) {
log.verbose(bundle.id, 'async chunks', asyncChunks);
}
if (miscFiles.length) {
log.verbose(bundle.id, 'misc files', asyncChunks);
}
const sumSize = (files: Entry[]) => files.reduce((acc: number, f) => acc + f.stats!.size, 0);
const bundleMetrics: CiStatsMetrics = [
{
group: `@kbn/optimizer bundle module count`,
id: bundle.id,
value: bundle.cache.getModuleCount() || 0,
},
{
group: `page load bundle size`,
id: bundle.id,
value: entry.stats!.size,
limit: config.limits.pageLoadAssetSize?.[bundle.id],
limitConfigPath: `packages/kbn-optimizer/limits.yml`,
},
{
group: `async chunks size`,
id: bundle.id,
value: sumSize(asyncChunks),
},
{
group: `async chunk count`,
id: bundle.id,
value: asyncChunks.length,
},
{
group: `miscellaneous assets size`,
id: bundle.id,
value: sumSize(miscFiles),
},
];
log.debug(bundle.id, 'metrics', bundleMetrics);
return bundleMetrics;
})
);
}

View file

@ -48,7 +48,12 @@ it('returns a bundle for core and each plugin', () => {
},
],
'/repo',
'/output'
'/output',
{
pageLoadAssetSize: {
box: 123,
},
}
).map((b) => b.toSpec())
).toMatchInlineSnapshot(`
Array [
@ -58,6 +63,7 @@ it('returns a bundle for core and each plugin', () => {
"id": "foo",
"manifestPath": <repoRoot>/plugins/foo/kibana.json,
"outputDir": <outputRoot>/plugins/foo/target/public,
"pageLoadAssetSizeLimit": undefined,
"publicDirNames": Array [
"public",
],
@ -70,6 +76,7 @@ it('returns a bundle for core and each plugin', () => {
"id": "baz",
"manifestPath": <outsideOfRepo>/plugins/baz/kibana.json,
"outputDir": <outsideOfRepo>/plugins/baz/target/public,
"pageLoadAssetSizeLimit": undefined,
"publicDirNames": Array [
"public",
],
@ -84,6 +91,7 @@ it('returns a bundle for core and each plugin', () => {
"id": "box",
"manifestPath": <repoRoot>/x-pack/plugins/box/kibana.json,
"outputDir": <outputRoot>/x-pack/plugins/box/target/public,
"pageLoadAssetSizeLimit": 123,
"publicDirNames": Array [
"public",
],

View file

@ -9,13 +9,15 @@
import Path from 'path';
import { Bundle } from '../common';
import { Limits } from './optimizer_config';
import { KibanaPlatformPlugin } from './kibana_platform_plugins';
export function getPluginBundles(
plugins: KibanaPlatformPlugin[],
repoRoot: string,
outputRoot: string
outputRoot: string,
limits: Limits
) {
const xpackDirSlash = Path.resolve(repoRoot, 'x-pack') + Path.sep;
@ -39,6 +41,7 @@ export function getPluginBundles(
? `/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. \n` +
` * Licensed under the Elastic License 2.0; you may not use this file except in compliance with the Elastic License 2.0. */\n`
: undefined,
pageLoadAssetSizeLimit: limits.pageLoadAssetSize?.[p.id],
})
);
}

View file

@ -14,4 +14,3 @@ export * from './watch_bundles_for_changes';
export * from './run_workers';
export * from './bundle_cache';
export * from './handle_optimizer_completion';
export * from './get_output_stats';

View file

@ -435,7 +435,6 @@ describe('OptimizerConfig::create()', () => {
"cache": Symbol(parsed cache),
"dist": Symbol(parsed dist),
"inspectWorkers": Symbol(parsed inspect workers),
"limits": Symbol(limits),
"maxWorkerCount": Symbol(parsed max worker count),
"plugins": Symbol(new platform plugins),
"profileWebpack": Symbol(parsed profile webpack),
@ -457,7 +456,7 @@ describe('OptimizerConfig::create()', () => {
[Window],
],
"invocationCallOrder": Array [
21,
22,
],
"results": Array [
Object {
@ -480,7 +479,7 @@ describe('OptimizerConfig::create()', () => {
[Window],
],
"invocationCallOrder": Array [
24,
25,
],
"results": Array [
Object {
@ -498,13 +497,14 @@ describe('OptimizerConfig::create()', () => {
Symbol(new platform plugins),
Symbol(parsed repo root),
Symbol(parsed output root),
Symbol(limits),
],
],
"instances": Array [
[Window],
],
"invocationCallOrder": Array [
22,
23,
],
"results": Array [
Object {

View file

@ -211,6 +211,7 @@ export class OptimizerConfig {
}
static create(inputOptions: Options) {
const limits = readLimits();
const options = OptimizerConfig.parseOptions(inputOptions);
const plugins = findKibanaPlatformPlugins(options.pluginScanDirs, options.pluginPaths);
const bundles = [
@ -223,10 +224,11 @@ export class OptimizerConfig {
sourceRoot: options.repoRoot,
contextDir: Path.resolve(options.repoRoot, 'src/core'),
outputDir: Path.resolve(options.outputRoot, 'src/core/target/public'),
pageLoadAssetSizeLimit: limits.pageLoadAssetSize?.core,
}),
]
: []),
...getPluginBundles(plugins, options.repoRoot, options.outputRoot),
...getPluginBundles(plugins, options.repoRoot, options.outputRoot, limits),
];
return new OptimizerConfig(
@ -239,8 +241,7 @@ export class OptimizerConfig {
options.maxWorkerCount,
options.dist,
options.profileWebpack,
options.themeTags,
readLimits()
options.themeTags
);
}
@ -254,8 +255,7 @@ export class OptimizerConfig {
public readonly maxWorkerCount: number,
public readonly dist: boolean,
public readonly profileWebpack: boolean,
public readonly themeTags: ThemeTags,
public readonly limits: Limits
public readonly themeTags: ThemeTags
) {}
getWorkerConfig(optimizerCacheKey: unknown): WorkerConfig {

View file

@ -1,46 +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.
*/
import { materialize, mergeMap, dematerialize } from 'rxjs/operators';
import { CiStatsReporter, ToolingLog } from '@kbn/dev-utils';
import { OptimizerUpdate$ } from './run_optimizer';
import { OptimizerConfig, getMetrics } from './optimizer';
import { pipeClosure } from './common';
export function reportOptimizerStats(
reporter: CiStatsReporter,
config: OptimizerConfig,
log: ToolingLog
) {
return pipeClosure((update$: OptimizerUpdate$) =>
update$.pipe(
materialize(),
mergeMap(async (n) => {
if (n.kind === 'C') {
const metrics = getMetrics(log, config);
await reporter.metrics(metrics);
for (const metric of metrics) {
if (metric.limit != null && metric.value > metric.limit) {
const value = metric.value.toLocaleString();
const limit = metric.limit.toLocaleString();
log.warning(
`Metric [${metric.group}] for [${metric.id}] of [${value}] over the limit of [${limit}]`
);
}
}
}
return n;
}),
dematerialize()
)
);
}

View file

@ -0,0 +1,108 @@
/*
* 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.
*/
import Path from 'path';
import webpack from 'webpack';
import { RawSource } from 'webpack-sources';
import { CiStatsMetrics } from '@kbn/dev-utils';
import { Bundle } from '../common';
interface Asset {
name: string;
size: number;
}
const IGNORED_EXTNAME = ['.map', '.br', '.gz'];
export class BundleMetricsPlugin {
constructor(private readonly bundle: Bundle) {}
public apply(compiler: webpack.Compiler) {
const { bundle } = this;
compiler.hooks.emit.tap('BundleMetricsPlugin', (compilation) => {
const assets = Object.entries(compilation.assets)
.map(
([name, source]: [string, any]): Asset => ({
name,
size: source.size(),
})
)
.filter((asset) => {
const filename = Path.basename(asset.name);
if (filename.startsWith('.')) {
return false;
}
const ext = Path.extname(filename);
if (IGNORED_EXTNAME.includes(ext)) {
return false;
}
return true;
});
const entryName = `${bundle.id}.${bundle.type}.js`;
const entry = assets.find((a) => a.name === entryName);
if (!entry) {
throw new Error(
`Unable to find bundle entry named [${entryName}] in [${bundle.outputDir}]`
);
}
const chunkPrefix = `${bundle.id}.chunk.`;
const asyncChunks = assets.filter((a) => a.name.startsWith(chunkPrefix));
const miscFiles = assets.filter((a) => a !== entry && !asyncChunks.includes(a));
const sumSize = (files: Asset[]) => files.reduce((acc: number, a) => acc + a.size, 0);
const moduleCount = bundle.cache.getModuleCount();
if (moduleCount === undefined) {
throw new Error(`moduleCount wasn't populated by PopulateBundleCachePlugin`);
}
const bundleMetrics: CiStatsMetrics = [
{
group: `@kbn/optimizer bundle module count`,
id: bundle.id,
value: moduleCount,
},
{
group: `page load bundle size`,
id: bundle.id,
value: entry.size,
limit: bundle.pageLoadAssetSizeLimit,
limitConfigPath: `packages/kbn-optimizer/limits.yml`,
},
{
group: `async chunks size`,
id: bundle.id,
value: sumSize(asyncChunks),
},
{
group: `async chunk count`,
id: bundle.id,
value: asyncChunks.length,
},
{
group: `miscellaneous assets size`,
id: bundle.id,
value: sumSize(miscFiles),
},
];
const metricsSource = new RawSource(JSON.stringify(bundleMetrics, null, 2));
// see https://github.com/jantimon/html-webpack-plugin/blob/33d69f49e6e9787796402715d1b9cd59f80b628f/index.js#L266
// @ts-expect-error undocumented, used to add assets to the output
compilation.emitAsset('metrics.json', metricsSource);
});
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.
*/
import Fs from 'fs';
import Path from 'path';
import webpack from 'webpack';
import { Bundle } from '../common';
export class EmitStatsPlugin {
constructor(private readonly bundle: Bundle) {}
public apply(compiler: webpack.Compiler) {
compiler.hooks.done.tap(
{
name: 'EmitStatsPlugin',
// run at the very end, ensure that it's after clean-webpack-plugin
stage: 10,
},
(stats) => {
Fs.writeFileSync(
Path.resolve(this.bundle.outputDir, 'stats.json'),
JSON.stringify(stats.toJson())
);
}
);
}
}

View file

@ -0,0 +1,132 @@
/*
* 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.
*/
import webpack from 'webpack';
import Path from 'path';
import { inspect } from 'util';
import { Bundle, WorkerConfig, ascending, parseFilePath } from '../common';
import { BundleRefModule } from './bundle_ref_module';
import {
isExternalModule,
isNormalModule,
isIgnoredModule,
isConcatenatedModule,
getModulePath,
} from './webpack_helpers';
/**
* sass-loader creates about a 40% overhead on the overall optimizer runtime, and
* so this constant is used to indicate to assignBundlesToWorkers() that there is
* extra work done in a bundle that has a lot of scss imports. The value is
* arbitrary and just intended to weigh the bundles so that they are distributed
* across mulitple workers on machines with lots of cores.
*/
const EXTRA_SCSS_WORK_UNITS = 100;
export class PopulateBundleCachePlugin {
constructor(private readonly workerConfig: WorkerConfig, private readonly bundle: Bundle) {}
public apply(compiler: webpack.Compiler) {
const { bundle, workerConfig } = this;
compiler.hooks.emit.tap(
{
name: 'PopulateBundleCachePlugin',
before: ['BundleMetricsPlugin'],
},
(compilation) => {
const bundleRefExportIds: string[] = [];
const referencedFiles = new Set<string>();
let moduleCount = 0;
let workUnits = compilation.fileDependencies.size;
if (bundle.manifestPath) {
referencedFiles.add(bundle.manifestPath);
}
for (const module of compilation.modules) {
if (isNormalModule(module)) {
moduleCount += 1;
const path = getModulePath(module);
const parsedPath = parseFilePath(path);
if (!parsedPath.dirs.includes('node_modules')) {
referencedFiles.add(path);
if (path.endsWith('.scss')) {
workUnits += EXTRA_SCSS_WORK_UNITS;
for (const depPath of module.buildInfo.fileDependencies) {
referencedFiles.add(depPath);
}
}
continue;
}
const nmIndex = parsedPath.dirs.lastIndexOf('node_modules');
const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@');
referencedFiles.add(
Path.join(
parsedPath.root,
...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)),
'package.json'
)
);
continue;
}
if (module instanceof BundleRefModule) {
bundleRefExportIds.push(module.ref.exportId);
continue;
}
if (isConcatenatedModule(module)) {
moduleCount += module.modules.length;
continue;
}
if (isExternalModule(module) || isIgnoredModule(module)) {
continue;
}
throw new Error(`Unexpected module type: ${inspect(module)}`);
}
const files = Array.from(referencedFiles).sort(ascending((p) => p));
const mtimes = new Map(
files.map((path): [string, number | undefined] => {
try {
return [path, compiler.inputFileSystem.statSync(path)?.mtimeMs];
} catch (error) {
if (error?.code === 'ENOENT') {
return [path, undefined];
}
throw error;
}
})
);
bundle.cache.set({
bundleRefExportIds: bundleRefExportIds.sort(ascending((p) => p)),
optimizerCacheKey: workerConfig.optimizerCacheKey,
cacheKey: bundle.createCacheKey(files, mtimes),
moduleCount,
workUnits,
files,
});
// write the cache to the compilation so that it isn't cleaned by clean-webpack-plugin
bundle.cache.writeWebpackAsset(compilation);
}
);
}
}

View file

@ -8,46 +8,16 @@
import 'source-map-support/register';
import Fs from 'fs';
import Path from 'path';
import { inspect } from 'util';
import webpack, { Stats } from 'webpack';
import * as Rx from 'rxjs';
import { mergeMap, map, mapTo, takeUntil } from 'rxjs/operators';
import {
CompilerMsgs,
CompilerMsg,
maybeMap,
Bundle,
WorkerConfig,
ascending,
parseFilePath,
BundleRefs,
} from '../common';
import { BundleRefModule } from './bundle_ref_module';
import { CompilerMsgs, CompilerMsg, maybeMap, Bundle, WorkerConfig, BundleRefs } from '../common';
import { getWebpackConfig } from './webpack.config';
import { isFailureStats, failedStatsToErrorMessage } from './webpack_helpers';
import {
isExternalModule,
isNormalModule,
isIgnoredModule,
isConcatenatedModule,
getModulePath,
} from './webpack_helpers';
const PLUGIN_NAME = '@kbn/optimizer';
/**
* sass-loader creates about a 40% overhead on the overall optimizer runtime, and
* so this constant is used to indicate to assignBundlesToWorkers() that there is
* extra work done in a bundle that has a lot of scss imports. The value is
* arbitrary and just intended to weigh the bundles so that they are distributed
* across mulitple workers on machines with lots of cores.
*/
const EXTRA_SCSS_WORK_UNITS = 100;
/**
* Create an Observable<CompilerMsg> for a specific child compiler + bundle
*/
@ -80,13 +50,6 @@ const observeCompiler = (
return undefined;
}
if (workerConfig.profileWebpack) {
Fs.writeFileSync(
Path.resolve(bundle.outputDir, 'stats.json'),
JSON.stringify(stats.toJson())
);
}
if (!workerConfig.watch) {
process.nextTick(() => done$.next());
}
@ -97,88 +60,11 @@ const observeCompiler = (
});
}
const bundleRefExportIds: string[] = [];
const referencedFiles = new Set<string>();
let moduleCount = 0;
let workUnits = stats.compilation.fileDependencies.size;
if (bundle.manifestPath) {
referencedFiles.add(bundle.manifestPath);
const moduleCount = bundle.cache.getModuleCount();
if (moduleCount === undefined) {
throw new Error(`moduleCount wasn't populated by PopulateBundleCachePlugin`);
}
for (const module of stats.compilation.modules) {
if (isNormalModule(module)) {
moduleCount += 1;
const path = getModulePath(module);
const parsedPath = parseFilePath(path);
if (!parsedPath.dirs.includes('node_modules')) {
referencedFiles.add(path);
if (path.endsWith('.scss')) {
workUnits += EXTRA_SCSS_WORK_UNITS;
for (const depPath of module.buildInfo.fileDependencies) {
referencedFiles.add(depPath);
}
}
continue;
}
const nmIndex = parsedPath.dirs.lastIndexOf('node_modules');
const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@');
referencedFiles.add(
Path.join(
parsedPath.root,
...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)),
'package.json'
)
);
continue;
}
if (module instanceof BundleRefModule) {
bundleRefExportIds.push(module.ref.exportId);
continue;
}
if (isConcatenatedModule(module)) {
moduleCount += module.modules.length;
continue;
}
if (isExternalModule(module) || isIgnoredModule(module)) {
continue;
}
throw new Error(`Unexpected module type: ${inspect(module)}`);
}
const files = Array.from(referencedFiles).sort(ascending((p) => p));
const mtimes = new Map(
files.map((path): [string, number | undefined] => {
try {
return [path, compiler.inputFileSystem.statSync(path)?.mtimeMs];
} catch (error) {
if (error?.code === 'ENOENT') {
return [path, undefined];
}
throw error;
}
})
);
bundle.cache.set({
bundleRefExportIds: bundleRefExportIds.sort(ascending((p) => p)),
optimizerCacheKey: workerConfig.optimizerCacheKey,
cacheKey: bundle.createCacheKey(files, mtimes),
moduleCount,
workUnits,
files,
});
return compilerMsgs.compilerSuccess({
moduleCount,
});

View file

@ -19,6 +19,9 @@ import * as UiSharedDeps from '@kbn/ui-shared-deps';
import { Bundle, BundleRefs, WorkerConfig } from '../common';
import { BundleRefsPlugin } from './bundle_refs_plugin';
import { BundleMetricsPlugin } from './bundle_metrics_plugin';
import { EmitStatsPlugin } from './emit_stats_plugin';
import { PopulateBundleCachePlugin } from './populate_bundle_cache_plugin';
const BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset');
@ -65,6 +68,9 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker:
plugins: [
new CleanWebpackPlugin(),
new BundleRefsPlugin(bundle, bundleRefs),
new PopulateBundleCachePlugin(worker, bundle),
new BundleMetricsPlugin(bundle),
...(worker.profileWebpack ? [new EmitStatsPlugin(bundle)] : []),
...(bundle.banner ? [new webpack.BannerPlugin({ banner: bundle.banner, raw: true })] : []),
],

View file

@ -74,13 +74,14 @@ it('builds a generated plugin into a viable archive', async () => {
await extract(PLUGIN_ARCHIVE, { dir: TMP_DIR });
const files = await globby(['**/*'], { cwd: TMP_DIR });
const files = await globby(['**/*'], { cwd: TMP_DIR, dot: true });
files.sort((a, b) => a.localeCompare(b));
expect(files).toMatchInlineSnapshot(`
Array [
"kibana/fooTestPlugin/common/index.js",
"kibana/fooTestPlugin/kibana.json",
"kibana/fooTestPlugin/node_modules/.yarn-integrity",
"kibana/fooTestPlugin/package.json",
"kibana/fooTestPlugin/server/index.js",
"kibana/fooTestPlugin/server/plugin.js",

View file

@ -34,9 +34,15 @@ export async function optimize({ log, plugin, sourceDir, buildDir }: BuildContex
pluginScanDirs: [],
});
const target = Path.resolve(sourceDir, 'target');
await runOptimizer(config).pipe(logOptimizerState(log, config)).toPromise();
// clean up unnecessary files
Fs.unlinkSync(Path.resolve(target, 'public/metrics.json'));
Fs.unlinkSync(Path.resolve(target, 'public/.kbn-optimizer-cache'));
// move target into buildDir
await asyncRename(Path.resolve(sourceDir, 'target'), Path.resolve(buildDir, 'target'));
await asyncRename(target, Path.resolve(buildDir, 'target'));
log.indent(-2);
}

10
scripts/ship_ci_stats.js Normal file
View file

@ -0,0 +1,10 @@
/*
* 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.
*/
require('../src/setup_node_env/no_transpilation');
require('@kbn/dev-utils').shipCiStatsCli();

View file

@ -6,20 +6,18 @@
* Side Public License, v 1.
*/
import { REPO_ROOT } from '@kbn/utils';
import { CiStatsReporter } from '@kbn/dev-utils';
import {
runOptimizer,
OptimizerConfig,
logOptimizerState,
reportOptimizerStats,
} from '@kbn/optimizer';
import Path from 'path';
import { Task } from '../lib';
import { REPO_ROOT } from '@kbn/utils';
import { lastValueFrom } from '@kbn/std';
import { CiStatsMetrics } from '@kbn/dev-utils';
import { runOptimizer, OptimizerConfig, logOptimizerState } from '@kbn/optimizer';
import { Task, deleteAll, write, read } from '../lib';
export const BuildKibanaPlatformPlugins: Task = {
description: 'Building distributable versions of Kibana platform plugins',
async run(_, log, build) {
async run(buildConfig, log, build) {
const config = OptimizerConfig.create({
repoRoot: REPO_ROOT,
outputRoot: build.resolvePath(),
@ -31,12 +29,27 @@ export const BuildKibanaPlatformPlugins: Task = {
includeCoreBundle: true,
});
const reporter = CiStatsReporter.fromEnv(log);
await lastValueFrom(runOptimizer(config).pipe(logOptimizerState(log, config)));
await runOptimizer(config)
.pipe(reportOptimizerStats(reporter, config, log), logOptimizerState(log, config))
.toPromise();
const combinedMetrics: CiStatsMetrics = [];
const metricFilePaths: string[] = [];
for (const bundle of config.bundles) {
const path = Path.resolve(bundle.outputDir, 'metrics.json');
const metrics: CiStatsMetrics = JSON.parse(await read(path));
combinedMetrics.push(...metrics);
metricFilePaths.push(path);
}
// write combined metrics to target
await write(
buildConfig.resolveFromTarget('optimizer_bundle_metrics.json'),
JSON.stringify(combinedMetrics, null, 2)
);
// delete all metric files
await deleteAll(metricFilePaths, log);
// delete all bundle cache files
await Promise.all(config.bundles.map((b) => b.cache.clear()));
},
};

View file

@ -5,6 +5,10 @@ source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh"
echo " -> building and extracting OSS Kibana distributable for use in functional tests"
node scripts/build --debug --oss
echo " -> shipping metrics from build to ci-stats"
node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json
linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')"
installDir="$PARENT_DIR/install/kibana"
mkdir -p "$installDir"

View file

@ -15,5 +15,8 @@ node scripts/ensure_all_tests_in_ci_group;
echo " -> building and extracting OSS Kibana distributable for use in functional tests"
node scripts/build --debug --oss
echo " -> shipping metrics from build to ci-stats"
node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json
mkdir -p "$WORKSPACE/kibana-build-oss"
cp -pR build/oss/kibana-*-SNAPSHOT-linux-x86_64/. $WORKSPACE/kibana-build-oss/

View file

@ -6,6 +6,10 @@ source "$KIBANA_DIR/src/dev/ci_setup/setup_percy.sh"
echo " -> building and extracting default Kibana distributable"
cd "$KIBANA_DIR"
node scripts/build --debug --no-oss
echo " -> shipping metrics from build to ci-stats"
node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json
linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')"
installDir="$KIBANA_DIR/install/kibana"
mkdir -p "$installDir"

View file

@ -30,6 +30,10 @@ node scripts/functional_tests --assert-none-excluded \
echo " -> building and extracting default Kibana distributable for use in functional tests"
cd "$KIBANA_DIR"
node scripts/build --debug --no-oss
echo " -> shipping metrics from build to ci-stats"
node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json
linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')"
installDir="$KIBANA_DIR/install/kibana"
mkdir -p "$installDir"

View file

@ -6803,7 +6803,7 @@
dependencies:
"@types/webpack" "*"
"@types/webpack-sources@*":
"@types/webpack-sources@*", "@types/webpack-sources@^0.1.4":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-0.1.5.tgz#be47c10f783d3d6efe1471ff7f042611bd464a92"
integrity sha512-zfvjpp7jiafSmrzJ2/i3LqOyTYTuJ7u1KOXlKgDlvsj9Rr0x7ZiYu5lZbXwobL7lmsRNtPXlBfmaUD8eU2Hu8w==