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
42 changes: 42 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Performance

on:
pull_request:
push:
branches:
- main

permissions: {}

concurrency:
group: ${{ github.ref }}-${{ github.workflow }}
cancel-in-progress: true

jobs:
perf:
name: Startup time regression check
permissions:
contents: read
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=6144
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22.x

- name: Install
run: npm ci

- name: Build
run: npm run build

- name: Install hyperfine
run: sudo apt-get install -y hyperfine

- name: Run perf check
run: scripts/perf-check
2 changes: 1 addition & 1 deletion NOTICE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,7 @@ THIS SOFTWARE.


------------------------------------------------------------------------
yaml@2.8.4
yaml@2.9.0
License: ISC
Repository: https://github.com/eemeli/yaml
Publisher: Eemeli Aro <eemeli@gmail.com>
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"scripts": {
"prepare": "git config core.hooksPath .githooks || true",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b",
"postbuild": "node -e \"const {unlinkSync,readdirSync,existsSync}=require('fs'),{join}=require('path'),d='dist/es/apis/schemas';if(existsSync(d))readdirSync(d).filter(f=>/\\.(d\\.ts|d\\.ts\\.map|js\\.map)$/.test(f)).forEach(f=>unlinkSync(join(d,f)))\"",
"postbuild": "node -e \"const {unlinkSync,readdirSync,existsSync}=require('fs'),{join}=require('path'),d='dist/es/apis/schemas';if(existsSync(d))readdirSync(d).filter(f=>/\\.(d\\.ts|d\\.ts\\.map|js\\.map)$/.test(f)).forEach(f=>unlinkSync(join(d,f)))\" && node scripts/create-dist-package-jsons.mjs",
"test": "npm run build && npm run test:unit && npm run test:license",
"test:unit": "node --max-old-space-size=8192 --import tsx/esm --test --experimental-test-coverage --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=90 --test-coverage-include='src/**/*.ts' --test-coverage-include='packages/*/src/**/*.ts' --test-coverage-exclude='src/cloud/apis/**' --test-coverage-exclude='src/es/apis.ts' --test-coverage-exclude='src/es/api-manifest.ts' --test-coverage-exclude='src/es/apis/**' --test-coverage-exclude='src/cloud/apis.ts' --test-coverage-exclude='src/cloud/serverless-apis.ts' --test-coverage-exclude='node_modules/**'",
"test:lint": "eslint src packages",
Expand Down Expand Up @@ -84,5 +84,10 @@
"tsx": "4.22.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.4"
}
},
"bundleDependencies": [
"@elastic/config-resolver",
"@elastic/es-schemas",
"zod"
]
}
15 changes: 3 additions & 12 deletions scripts/build-api-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const APIS_DIR = join(ROOT, 'src', 'es', 'apis')
const OUT = join(ROOT, 'src', 'es', 'api-manifest.ts')

const DEF_RE = /^\s*\{\s*$([\s\S]*?)^\s*\},/gm
const FIELD_RE = /^\s*(name|namespace|description|method|path|responseType|bodyFormat):\s*("(?:[^"\\]|\\.)*"|'[^']*'),?\s*$/gm
const FIELD_RE = /^\s*(name|namespace|description):\s*("(?:[^"\\]|\\.)*"|'[^']*'),?\s*$/gm

const entries = []
const files = (await readdir(APIS_DIR)).filter(f => f.endsWith('.ts') && f !== 'types.ts')
Expand All @@ -27,17 +27,12 @@ for (const file of files.sort()) {
fields[fm[1]] = JSON.parse(fm[2].replace(/^'(.*)'$/, '"$1"'))
}
if (!fields.name) continue
const entry = {
entries.push({
name: fields.name,
namespace: fields.namespace ?? null,
description: fields.description ?? '',
method: fields.method,
path: fields.path,
namespaceFile: fileStem,
}
if (fields.responseType) entry.responseType = fields.responseType
if (fields.bodyFormat) entry.bodyFormat = fields.bodyFormat
entries.push(entry)
})
}
}

Expand All @@ -58,10 +53,6 @@ export interface EsApiMeta {
readonly name: string
readonly namespace: string | null
readonly description: string
readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD'
readonly path: string
readonly responseType?: 'json' | 'text'
readonly bodyFormat?: 'json' | 'ndjson'
/** File stem under src/es/apis/ that holds the full EsApiDefinition. */
readonly namespaceFile: string
}
Expand Down
30 changes: 12 additions & 18 deletions scripts/build-kb-manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ function toCamelCase (stem: string): string {
}

interface Entry {
def: KbApiDefinition
file: string
name: string
namespace: string
description: string
namespaceFile: string
}

const entries: Entry[] = []
Expand All @@ -25,19 +27,15 @@ for (const file of files) {
throw new Error(`src/kb/apis/${file} did not export ${exportName}`)
}
for (const def of defs) {
entries.push({ def, file: stem })
entries.push({
name: def.name,
namespace: def.namespace,
description: def.description,
namespaceFile: stem,
})
}
}

const manifest = entries.map(({ def, file }) => ({
name: def.name,
namespace: def.namespace,
description: def.description,
method: def.method,
path: def.path,
namespaceFile: file,
}))

const lines = [
'/*',
' * Copyright Elasticsearch B.V. and contributors',
Expand All @@ -49,22 +47,18 @@ const lines = [
' * DO NOT EDIT BY HAND. Regenerate after running the code generator.',
' */',
'',
"import type { HttpMethod } from './types.ts'",
'',
'/** Cheap metadata for every Kibana API command. No Zod schemas built. */',
'export interface KbApiMeta {',
' readonly name: string',
' readonly namespace: string',
' readonly description: string',
' readonly method: HttpMethod',
' readonly path: string',
' /** File stem under src/kb/apis/ that holds the full KbApiDefinition. */',
' readonly namespaceFile: string',
'}',
'',
'export const kbApiManifest: readonly KbApiMeta[] = ' + JSON.stringify(manifest, null, 2),
`export const kbApiManifest: readonly KbApiMeta[] = ${JSON.stringify(entries, null, 2)} as const`,
'',
]

fs.writeFileSync('./src/kb/api-manifest.ts', lines.join('\n'))
console.log(`Wrote manifest with ${manifest.length} entries`)
console.log(`Wrote manifest with ${entries.length} entries`)
36 changes: 36 additions & 0 deletions scripts/create-dist-package-jsons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'

const root = join(fileURLToPath(import.meta.url), '..', '..')
const content = '{"type":"module"}\n'

// Create package.json in each dist subdirectory to short-circuit Node.js's
// upward package.json walk when resolving module types, saving ENOENT syscalls.
const dirs = [
'dist',
'dist/cloud',
'dist/completion',
'dist/completion/completers',
'dist/completion/shells',
'dist/config',
'dist/docs',
'dist/es',
'dist/extension',
'dist/kb',
'dist/lib',
'dist/sanitize',
'dist/status',
]

for (const dir of dirs) {
const path = join(root, dir, 'package.json')
if (existsSync(join(root, dir))) {
writeFileSync(path, content)
}
}
113 changes: 113 additions & 0 deletions scripts/perf-check
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env node

/*
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

// Benchmark CLI startup time against recorded baselines from PR #400.
// Fails if any command's mean runtime exceeds its baseline by more than 20%.
//
// Usage: scripts/perf-check
// Override warmup/runs for local profiling:
// PERF_WARMUP=5 PERF_RUNS=30 scripts/perf-check

import { execFileSync } from "node:child_process"
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
const CLI = join(REPO_ROOT, "dist", "cli.js")

// Tuned for CI: fast enough to finish in ~60 s on a shared runner while still
// producing a stable mean. Override with env vars for local profiling.
const WARMUP = Number(process.env.PERF_WARMUP ?? 3)
const MIN_RUNS = Number(process.env.PERF_RUNS ?? 15)

// Baselines (mean ms) recorded after the optimisations in PR #400.
// Measured with: hyperfine -w 10 -m 100 <command>
// Hardware: Lenovo ThinkPad X1 Carbon, i7-1365U, 16 GB RAM, Arch Linux
const BASELINES_MS = {
"elastic": 99.0,
"elastic --help": 99.4,
"elastic es --help": 93.3,
"elastic es": 54.0,
"elastic cloud --help": 59.4,
"elastic cloud": 42.4,
"elastic kb --help": 83.7,
"elastic kb": 76.9,
}



const THRESHOLD = 1.40 // fail if mean performance is 40% slower than baseline

// build the list of full commands to benchmark, one entry per baseline key
const commands = Object.keys(BASELINES_MS).map((label) => {
const args = label.slice("elastic".length).trim()
return args.length > 0 ? `node ${CLI} ${args}` : `node ${CLI}`
})

const tmpDir = mkdtempSync(join(tmpdir(), "elastic-cli-bench-"))
const jsonOut = join(tmpDir, "results.json")

try {
execFileSync(
"hyperfine",
[
"--warmup", String(WARMUP),
"--min-runs", String(MIN_RUNS),
"--export-json", jsonOut,
"--shell=none",
...commands,
],
{ stdio: "inherit" },
)

const raw = JSON.parse(readFileSync(jsonOut, "utf8"))

// hyperfine stores mean in seconds; convert to ms
// normalise the command string back to a BASELINES_MS key by stripping the
// absolute path prefix and collapsing any extra whitespace
const results = raw.results.map((r) => ({
command: r.command
.replace(/^node\s+.*?dist[/\\]cli\.js/, "elastic")
.replace(/\s+/g, " ")
.trim(),
meanMs: r.mean * 1000,
}))

let failed = false

for (const { command, meanMs } of results) {
const baseline = BASELINES_MS[command]
if (baseline == null) {
console.warn(`⚠ No baseline for "${command}" — skipping`)
continue
}
const limit = baseline * THRESHOLD
const pct = ((meanMs / baseline - 1) * 100).toFixed(1)
const sign = meanMs > baseline ? "+" : ""
const ok = meanMs <= limit
const icon = ok ? "✓" : "✗"
console.log(
`${icon} ${command.padEnd(24)} mean ${meanMs.toFixed(1)} ms ` +
`(baseline ${baseline} ms, ${sign}${pct}%, limit ${limit.toFixed(1)} ms)`,
)
if (!ok) failed = true
}

if (failed) {
console.error(
"\nPerformance regression detected. " +
"One or more commands exceeded their baseline by more than 20%.",
)
process.exit(1)
}

console.log("\nAll commands within the 20% regression threshold.")
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
Loading
Loading