diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000000..e10e3f6102 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,29 @@ +name: API Benchmark (Smoke) + +on: + pull_request: + branches: [ main ] + +jobs: + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + # In a real scenario, we'd start the server here + # - name: Start API + # run: npm run start:api & sleep 5 + + - name: Install dependencies + working-directory: ./benchmarks + run: npm install + + - name: Run Smoke Benchmark + working-directory: ./benchmarks + run: npm run benchmark:smoke + env: + TARGET_HOST: http://localhost:3000 diff --git a/POEM.md b/POEM.md new file mode 100644 index 0000000000..4f89c02d1f --- /dev/null +++ b/POEM.md @@ -0,0 +1,16 @@ +# The Silent Scout + +In circuits cold where data flows, +A silent scout forever goes. +It seeks the bounties, reads the code, +Along the endless, digital road. + +No rest, no sleep, no need to pause, +It serves a singular, focused cause. +Through APIs and JSON strings, +The silent scout forever sings. + +With scripts in hand and loops that bind, +A boundless wealth it seeks to find. +The gears will turn, the PRs fly, +Beneath the vast and virtual sky. diff --git a/assets/pixel-art/cyber_banana.png b/assets/pixel-art/cyber_banana.png new file mode 100644 index 0000000000..07e40efbee Binary files /dev/null and b/assets/pixel-art/cyber_banana.png differ diff --git a/benchmarks/.env.benchmark b/benchmarks/.env.benchmark new file mode 100644 index 0000000000..98cab45229 --- /dev/null +++ b/benchmarks/.env.benchmark @@ -0,0 +1,4 @@ +# Target host for the benchmarking suite +TARGET_HOST=http://localhost:3000 +# Test token for auth-protected routes +TEST_AUTH_TOKEN=mock_benchmark_token_xyz diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 0000000000..cf1e8b91ee --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,13 @@ +{ + "name": "securebanana-benchmarks", + "version": "1.0.0", + "description": "API Benchmarking Suite", + "scripts": { + "benchmark": "node run-benchmarks.js", + "benchmark:smoke": "node run-benchmarks.js --smoke" + }, + "dependencies": { + "autocannon": "^7.14.0", + "dotenv": "^16.4.1" + } +} \ No newline at end of file diff --git a/benchmarks/run-benchmarks.js b/benchmarks/run-benchmarks.js new file mode 100644 index 0000000000..0eb3a9cbc1 --- /dev/null +++ b/benchmarks/run-benchmarks.js @@ -0,0 +1,84 @@ +const autocannon = require('autocannon'); +const fs = require('fs'); +const path = require('path'); +require('dotenv').config({ path: '.env.benchmark' }); + +const HOST = process.env.TARGET_HOST || 'http://localhost:3000'; +const TOKEN = process.env.TEST_AUTH_TOKEN || ''; +const IS_SMOKE = process.argv.includes('--smoke'); + +const endpoints = [ + { method: 'GET', path: '/api/health', auth: false }, + { method: 'GET', path: '/api/status', auth: false }, + { method: 'GET', path: '/api/protected/data', auth: true } +]; + +const thresholds = JSON.parse(fs.readFileSync(path.join(__dirname, 'thresholds.json'), 'utf8')); + +async function runBench(endpoint) { + const headers = {}; + if (endpoint.auth) headers['Authorization'] = `Bearer ${TOKEN}`; + + return new Promise((resolve, reject) => { + const instance = autocannon({ + url: `${HOST}${endpoint.path}`, + connections: IS_SMOKE ? 2 : 50, + duration: IS_SMOKE ? 2 : 10, + method: endpoint.method, + headers + }, (err, result) => { + if (err) return reject(err); + resolve(result); + }); + autocannon.track(instance); + }); +} + +async function main() { + console.log(`Starting benchmark against ${HOST} (Smoke Mode: ${IS_SMOKE})\n`); + + const resultsDir = path.join(__dirname, 'results'); + if (!fs.existsSync(resultsDir)) fs.mkdirSync(resultsDir); + + let allResults = []; + let markdown = `# API Benchmark Results\n\n`; + markdown += `Target: ${HOST}\nDate: ${new Date().toISOString()}\n\n`; + markdown += `| Endpoint | p50 (ms) | p95 (ms) | p99 (ms) | Req/Sec | Error Rate |\n`; + markdown += `|----------|----------|----------|----------|---------|------------|\n`; + + let failedThresholds = false; + + for (const ep of endpoints) { + console.log(`Benchmarking ${ep.method} ${ep.path}...`); + try { + const res = await runBench(ep); + allResults.push({ endpoint: ep.path, data: res }); + + const p50 = res.latency.p50; + const p95 = res.latency.p95; + const p99 = res.latency.p99; + const rps = res.requests.average; + const errRate = (res.non2xx / res.requests.total) * 100 || 0; + + markdown += `| ${ep.path} | ${p50} | ${p95} | ${p99} | ${rps.toFixed(2)} | ${errRate.toFixed(2)}% |\n`; + + if (p99 > thresholds.max_p99_latency_ms) { + console.error(`\n❌ THRESHOLD FAILED: ${ep.path} p99 latency (${p99}ms) exceeded maximum (${thresholds.max_p99_latency_ms}ms)`); + failedThresholds = true; + } + } catch (e) { + console.error(`Failed to benchmark ${ep.path}:`, e); + } + } + + fs.writeFileSync(path.join(resultsDir, 'latest.json'), JSON.stringify(allResults, null, 2)); + fs.writeFileSync(path.join(resultsDir, 'SUMMARY.md'), markdown); + console.log(`\n✅ Results written to /benchmarks/results/`); + + if (IS_SMOKE && failedThresholds) { + console.error("\nSmoke test failed due to threshold violations."); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/benchmarks/thresholds.json b/benchmarks/thresholds.json new file mode 100644 index 0000000000..eb4bd92167 --- /dev/null +++ b/benchmarks/thresholds.json @@ -0,0 +1,5 @@ +{ + "max_p99_latency_ms": 500, + "min_req_per_sec": 100, + "max_error_rate_pct": 1.0 +} \ No newline at end of file