// @ts-check /** * Zatezovy test portalu pres HTTP. * * Virtualni uzivatele se prihlasi a v smycce delaji to, co dela prohlizec * po otevreni prehledu: prava, seznam ticketu, detail, souhrn, data widgetu * a lide. Kazdy desaty uzivatel drzi otevreny zivy stream (SSE). Na konci * se vypise tabulka s p50/p95/p99 za endpoint, propustnost a chybovost * a totez se zapise do `load-report.json` v pracovnim adresari (je v .gitignore). * * Obycejny Node bez zavislosti (`fetch`, `perf_hooks`), aby sel spustit * i bez `npm ci`. Cisla a jak je cist jsou v documentation/19-kapacita-200-firem.md. * * Promenne prostredi: * LOAD_URL adresa serveru, vychozi http://127.0.0.1:3000 * LOAD_EMAIL, LOAD_PASSWORD ucet, vychozi demo spravce platformy * LOAD_USERS pocet virtualnich uzivatelu, vychozi 100 * LOAD_DURATION_SEC delka behu, vychozi 30 * LOAD_WEBHOOK_TOKEN kdyz je nastaven, posle se navic davka udalosti na * /webhook/ a ceka se, az fronta dobehne * LOAD_WEBHOOK_EVENTS velikost davky, vychozi 2000 * LOAD_START_LOCAL=1 spusti `node dist/index.js` na volnem portu s docasnym * DATA_DIR (rezim souboru) a na konci ho ukonci * * Prihlaseni je jedno pro vsechny uzivatele: server pousti z jedne adresy * 20 prihlaseni za ctvrt hodiny a sto virtualnich uzivatelu by narazilo. */ import { execFile, spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // ------------------------------------------------------------------ nastaveni const DEFAULT_URL = 'http://127.0.0.1:3000'; const DEFAULT_EMAIL = 'admin@automia.cz'; const DEFAULT_PASSWORD = 'demo1234'; const DEFAULT_USERS = 100; const DEFAULT_DURATION_SEC = 30; const DEFAULT_WEBHOOK_EVENTS = 2_000; /** Jeden zivy stream na deset uzivatelu, jako kdyz ma kazdy desaty otevreny prehled. */ const USERS_PER_STREAM = 10; /** Stranka seznamu ticketu jako v portalu. */ const TICKET_PAGE_LIMIT = 50; /** * Dlazdice, ktere si vychozi rozlozeni pocita na serveru. Ostatni dlazdice * vychoziho rozlozeni berou data ze souhrnu a seznamu, viz * src/data/dashboardLayouts.ts a COMPUTED_BUILTINS v web/src/pages/dashboard/Overview.tsx. */ const DEFAULT_LAYOUT_WIDGET_IDS = ['list.myTickets', 'list.unassigned']; /** Kolik udalosti webhooku leti naraz. Odesilatel (e-shop) taky neceka na odpoved. */ const WEBHOOK_CONCURRENCY = 20; /** Jak casto se pta na stav fronty a jak dlouho se na vyprazdneni ceka nejvys. */ const QUEUE_POLL_MS = 500; const QUEUE_DRAIN_TIMEOUT_MS = 180_000; /** Cekani na start lokalniho serveru. */ const HEALTH_WAIT_MS = 30_000; const HEALTH_POLL_MS = 250; /** Jak casto se odecita pamet lokalniho serveru. */ const MEMORY_SAMPLE_MS = 2_000; /** Kolik se ceka na ukonceni lokalniho serveru, nez se zabije natvrdo. */ const STOP_TIMEOUT_MS = 10_000; const REPORT_FILE = 'load-report.json'; /** System hlasi pamet v kB, tabulka ji ukazuje v MB. */ const KB_PER_MB = 1024; const MS_PER_SECOND = 1_000; /** * @param {string} name * @param {number} fallback * @returns {number} */ function envNumber(name, fallback) { const parsed = Number(process.env[name]); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } const settings = { url: (process.env.LOAD_URL || DEFAULT_URL).replace(/\/+$/, ''), email: process.env.LOAD_EMAIL || DEFAULT_EMAIL, password: process.env.LOAD_PASSWORD || DEFAULT_PASSWORD, users: envNumber('LOAD_USERS', DEFAULT_USERS), durationSec: envNumber('LOAD_DURATION_SEC', DEFAULT_DURATION_SEC), webhookToken: (process.env.LOAD_WEBHOOK_TOKEN ?? '').trim(), webhookEvents: envNumber('LOAD_WEBHOOK_EVENTS', DEFAULT_WEBHOOK_EVENTS), startLocal: process.env.LOAD_START_LOCAL === '1', }; // -------------------------------------------------------------------- mereni /** @typedef {{ count: number, errors: number, latencies: number[] }} EndpointStats */ /** @type {Map} */ const endpoints = new Map(); /** * @param {string} name * @param {number} ms * @param {boolean} ok */ function record(name, ms, ok) { let stats = endpoints.get(name); if (!stats) { stats = { count: 0, errors: 0, latencies: [] }; endpoints.set(name, stats); } stats.count += 1; if (ok) stats.latencies.push(ms); else stats.errors += 1; } /** * @param {number[]} sorted * @param {number} share 0 az 1 * @returns {number} */ function percentile(sorted, share) { if (sorted.length === 0) return 0; const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * share) - 1)); return sorted[index] ?? 0; } /** * @param {EndpointStats} stats */ function summarise(stats) { const sorted = [...stats.latencies].sort((a, b) => a - b); return { count: stats.count, errors: stats.errors, p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95), p99: percentile(sorted, 0.99), max: sorted[sorted.length - 1] ?? 0, }; } /** * Jedno volani s merenim. Chyba site i odpoved mimo 2xx se pocita jako chyba * a nevyhazuje se - jeden spadly request nesmi zastavit virtualniho uzivatele. * @param {string} name popisek endpointu v tabulce * @param {string} url * @param {RequestInit} init * @returns {Promise} telo jako JSON, nebo null pri chybe */ async function call(name, url, init) { const start = performance.now(); try { const response = await fetch(url, init); const body = await response.json().catch(() => null); record(name, performance.now() - start, response.ok); if (!response.ok) return null; return body; } catch (err) { record(name, performance.now() - start, false); noteError(name, err); return null; } } /** @type {Map} */ const errorKinds = new Map(); /** * @param {string} name * @param {unknown} err */ function noteError(name, err) { const key = `${name}: ${err instanceof Error ? err.message : String(err)}`; errorKinds.set(key, (errorKinds.get(key) ?? 0) + 1); } // ------------------------------------------------------------ virtualni uzivatel /** * @param {string} token * @returns {Record} */ function authHeaders(token) { return { Authorization: `Bearer ${token}` }; } /** * @param {string} base * @param {string} token * @returns {Promise} */ async function login(base, token) { if (token) return token; const response = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: settings.email, password: settings.password }), }); if (!response.ok) { throw new Error(`prihlaseni selhalo: ${response.status} ${await response.text()}`); } const body = /** @type {{ token: string }} */ (await response.json()); return body.token; } /** * Smycka jednoho uzivatele: stejna posloupnost, jakou dela prehled v prohlizeci. * @param {string} base * @param {string} token * @param {number} deadline performance.now(), kdy skoncit */ async function virtualUser(base, token, deadline) { const headers = authHeaders(token); const jsonHeaders = { ...headers, 'Content-Type': 'application/json' }; /** @type {string[]} */ let ticketIds = []; while (performance.now() < deadline) { await call('GET /api/dashboard/access', `${base}/api/dashboard/access`, { headers }); const list = /** @type {{ items?: Array<{ id: string }> } | null} */ ( await call( `GET /api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`, `${base}/api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`, { headers }, ) ); if (list?.items) ticketIds = list.items.map((item) => item.id); const ticketId = ticketIds[Math.floor(Math.random() * ticketIds.length)]; if (ticketId) { await call('GET /api/dashboard/tickets/:id', `${base}/api/dashboard/tickets/${ticketId}`, { headers, }); } await call('GET /api/dashboard/summary', `${base}/api/dashboard/summary`, { headers }); await call('POST /api/dashboard/widget-data', `${base}/api/dashboard/widget-data`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify({ widgetIds: DEFAULT_LAYOUT_WIDGET_IDS }), }); await call('GET /api/dashboard/people', `${base}/api/dashboard/people`, { headers }); } } // ------------------------------------------------------------------ zivy stream /** @typedef {{ opened: number, events: number, errors: number }} StreamStats */ /** * Drzi jedno spojeni SSE az do zruseni a pocita prijate udalosti. * @param {string} base * @param {string} token * @param {AbortSignal} signal * @param {StreamStats} stats */ async function holdStream(base, token, signal, stats) { try { const response = await fetch(`${base}/api/dashboard/stream`, { headers: authHeaders(token), signal, }); if (!response.ok || !response.body) { stats.errors += 1; return; } stats.opened += 1; const reader = response.body.getReader(); const decoder = new TextDecoder(); for (;;) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); stats.events += text.split('\n').filter((line) => line.startsWith('event:')).length; } } catch (err) { // Zruseni na konci behu je ocekavane, cokoliv jineho je chyba spojeni. if (!signal.aborted) { stats.errors += 1; noteError('SSE /api/dashboard/stream', err); } } } // ---------------------------------------------------------------- davka webhooku /** * Stav fronty za vsechny firmy (ucet je spravce platformy, jinak jen za jeho firmy). * @param {string} base * @param {string} token * @returns {Promise<{ pending: number, running: number, failed: number, done: number }>} */ async function queueStats(base, token) { const response = await fetch(`${base}/api/dashboard/runs?scope=all&limit=1`, { headers: authHeaders(token), }); if (!response.ok) throw new Error(`stav fronty: ${response.status}`); const body = /** @type {{ stats: { pending: number, running: number, failed: number, done: number } }} */ ( await response.json() ); return body.stats; } /** * Telo udalosti podle kontraktu webhooku (`GET /webhook/` vraci ukazku). * Textova pole, ktera vypadaji jako identifikator, dostanou unikatni hodnotu, * aby kazda udalost byla nova a nesla na jeden ticket. * @param {unknown} example * @param {Array<{ name: string, path: string, type: string }>} expects * @param {number} index * @returns {Record} */ function eventBody(example, expects, index) { const body = /** @type {Record} */ ( JSON.parse(JSON.stringify(example && typeof example === 'object' ? example : {})) ); for (const field of expects) { if (field.type !== 'string' || !/id$/i.test(field.name)) continue; const segments = field.path.split('.'); const last = segments.pop(); if (last === undefined) continue; let target = body; for (const key of segments) { const next = target[key]; if (typeof next !== 'object' || next === null) { target[key] = {}; } target = /** @type {Record} */ (target[key]); } target[last] = `load-${index}`; } return body; } /** * @param {string} base * @param {string} token webhooku * @param {string} auth token prihlaseni pro cteni fronty * @param {number} count */ async function webhookBurst(base, token, auth, count) { const contractResponse = await fetch(`${base}/webhook/${token}`); if (!contractResponse.ok) { throw new Error(`webhook ${token.slice(0, 6)}...: ${contractResponse.status}`); } const contract = /** @type {{ example: unknown, expects: Array<{ name: string, path: string, type: string }> }} */ ( await contractResponse.json() ); const before = await queueStats(base, auth); const start = performance.now(); let accepted = 0; let rejected = 0; let next = 0; /** Jeden "odesilatel": posila dalsi a dalsi, dokud je co. */ async function sender() { while (next < count) { const index = next; next += 1; const body = await call('POST /webhook/:token', `${base}/webhook/${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(eventBody(contract.example, contract.expects, index)), }); if (body) accepted += 1; else rejected += 1; } } await Promise.all(Array.from({ length: WEBHOOK_CONCURRENCY }, sender)); const sentAt = performance.now(); // Cekani, az worker frontu vyprazdni. let stats = await queueStats(base, auth); while (stats.pending + stats.running > 0) { if (performance.now() - start > QUEUE_DRAIN_TIMEOUT_MS) { console.warn(`[load] fronta se nevyprazdnila do ${QUEUE_DRAIN_TIMEOUT_MS / MS_PER_SECOND} s`); break; } await sleep(QUEUE_POLL_MS); stats = await queueStats(base, auth); } const drainedAt = performance.now(); return { events: count, accepted, rejected, sendMs: Math.round(sentAt - start), drainMs: Math.round(drainedAt - start), runsPerSec: Math.round(accepted / ((drainedAt - start) / MS_PER_SECOND)), done: stats.done - before.done, failed: stats.failed - before.failed, pendingAtEnd: stats.pending + stats.running, }; } // --------------------------------------------------------------- lokalni server /** @returns {Promise} */ function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close(() => resolve(port)); }); }); } /** * true = na portu nikdo neposloucha. * @param {number} port * @returns {Promise} */ function portFree(port) { return new Promise((resolve) => { const socket = net.connect({ port, host: '127.0.0.1' }); socket.once('connect', () => { socket.destroy(); resolve(false); }); socket.once('error', () => resolve(true)); }); } /** * @param {number} ms * @returns {Promise} */ function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } /** * @param {string} base */ async function waitForHealth(base) { const deadline = performance.now() + HEALTH_WAIT_MS; while (performance.now() < deadline) { try { const response = await fetch(`${base}/health`); if (response.ok) return; } catch { // Server jeste neposloucha, zkusi se znovu. } await sleep(HEALTH_POLL_MS); } throw new Error(`server na ${base} nenabehl do ${HEALTH_WAIT_MS / MS_PER_SECOND} s`); } /** * Pamet procesu v MB (RSS). Server ji sam nevystavuje, tak se cte ze systemu. * @param {number} pid * @returns {Promise} */ async function residentMb(pid) { try { if (process.platform === 'win32') { const { stdout } = await execFileAsync('tasklist', [ '/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH', ]); const cells = stdout.trim().split(','); const last = cells[cells.length - 1] ?? ''; const kb = Number(last.replace(/[^0-9]/g, '')); return Number.isFinite(kb) && kb > 0 ? Math.round(kb / KB_PER_MB) : null; } const status = await fs.readFile(`/proc/${pid}/status`, 'utf8'); const match = /VmRSS:\s+(\d+)\s+kB/.exec(status); return match ? Math.round(Number(match[1]) / KB_PER_MB) : null; } catch (err) { console.warn( '[load] pamet serveru se nepodarila precist:', err instanceof Error ? err.message : err, ); return null; } } /** * Spusti sestaveny server na volnem portu s docasnym DATA_DIR (rezim souboru). * @param {string} webhookToken */ async function startLocal(webhookToken) { const port = await freePort(); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'csbot-load-')); const entry = path.join(root, 'dist', 'index.js'); await fs.access(entry).catch(() => { throw new Error(`chybi ${entry}, spustte npm run build`); }); /** @type {string[]} */ const stderr = []; const child = spawn(process.execPath, [entry], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, DATABASE_URL: '', SEED_DEMO: '1', JWT_SECRET: 'load-test-jwt-secret', SECRETS_KEY: 'load-test-secrets-key', ...(webhookToken ? { WEBHOOK_TOKEN_TEST: webhookToken } : {}), }, // Log serveru se zahazuje: kazdy request pise radek a stovky radku za // sekundu by v terminalu prekryly vysledek. Chyby se drzi pro hlaseni. stdio: ['ignore', 'ignore', 'pipe'], }); child.stderr?.on('data', (chunk) => { stderr.push(String(chunk)); if (stderr.length > 50) stderr.shift(); }); child.on('exit', (code) => { if (code !== null && code !== 0) { console.error(`[load] server skoncil s kodem ${code}\n${stderr.join('')}`); } }); const base = `http://127.0.0.1:${port}`; try { await waitForHealth(base); } catch (err) { child.kill(); await fs.rm(dataDir, { recursive: true, force: true }); throw err; } console.log(`[load] lokalni server bezi na ${base}, data v ${dataDir}`); return { base, port, pid: child.pid ?? 0, async stop() { if (child.exitCode === null) { const exited = new Promise((resolve) => child.once('exit', resolve)); child.kill('SIGTERM'); const timer = sleep(STOP_TIMEOUT_MS).then(() => { if (child.exitCode === null) child.kill('SIGKILL'); }); await Promise.race([exited, timer]); } await fs.rm(dataDir, { recursive: true, force: true }); const free = await portFree(port); console.log(`[load] server ukoncen, port ${port} ${free ? 'je volny' : 'JE STALE OBSAZENY'}`); return free; }, }; } // ---------------------------------------------------------------------- vystup /** * @param {Array>} rows */ function printTable(rows) { const widths = rows.reduce( (acc, row) => row.map((cell, index) => Math.max(acc[index] ?? 0, cell.length)), /** @type {number[]} */ ([]), ); for (const row of rows) { console.log(row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' ')); } } /** * @param {number} value */ function ms(value) { return value.toFixed(1); } // ------------------------------------------------------------------------ beh async function main() { /** @type {Awaited> | null} */ let local = null; let portFreed = true; try { if (settings.startLocal) local = await startLocal(settings.webhookToken); const base = local ? local.base : settings.url; const token = await login(base, ''); console.log( `[load] ${settings.users} uzivatelu, ${settings.durationSec} s, ${Math.ceil(settings.users / USERS_PER_STREAM)} streamu, cil ${base}`, ); /** @type {number[]} */ const memorySamples = []; const memoryTimer = local ? setInterval(() => { void residentMb(local.pid).then((value) => { if (value !== null) memorySamples.push(value); }); }, MEMORY_SAMPLE_MS) : null; const streamAbort = new AbortController(); /** @type {StreamStats} */ const streams = { opened: 0, events: 0, errors: 0 }; const streamCount = Math.ceil(settings.users / USERS_PER_STREAM); const streamTasks = Array.from({ length: streamCount }, () => holdStream(base, token, streamAbort.signal, streams), ); const startedAt = performance.now(); const deadline = startedAt + settings.durationSec * MS_PER_SECOND; await Promise.all( Array.from({ length: settings.users }, () => virtualUser(base, token, deadline)), ); const elapsedMs = performance.now() - startedAt; let webhook = null; if (settings.webhookToken) { console.log(`[load] davka ${settings.webhookEvents} udalosti na webhook`); webhook = await webhookBurst(base, settings.webhookToken, token, settings.webhookEvents); } streamAbort.abort(); await Promise.all(streamTasks); if (memoryTimer) clearInterval(memoryTimer); if (local) memorySamples.push((await residentMb(local.pid)) ?? 0); // ---------------------------------------------------------------- souhrn /** @type {Record>} */ const perEndpoint = {}; let requests = 0; let errors = 0; for (const [name, stats] of endpoints) { perEndpoint[name] = summarise(stats); // Davka webhooku se do propustnosti smycky uzivatelu nepocita, bezela az po ni. if (name.startsWith('POST /webhook')) continue; requests += stats.count; errors += stats.errors; } const seconds = elapsedMs / MS_PER_SECOND; const total = { requests, errors, errorRate: requests === 0 ? 0 : Math.round((errors / requests) * 10_000) / 100, seconds: Math.round(seconds * 10) / 10, requestsPerSec: Math.round(requests / seconds), }; const memory = memorySamples.length > 0 ? { rssMaxMb: Math.max(...memorySamples), rssLastMb: memorySamples[memorySamples.length - 1] ?? 0, } : null; console.log(''); printTable([ ['Endpoint', 'pozadavku', 'chyb', 'p50 ms', 'p95 ms', 'p99 ms', 'max ms'], ...Object.entries(perEndpoint).map(([name, row]) => [ name, String(row.count), String(row.errors), ms(row.p50), ms(row.p95), ms(row.p99), ms(row.max), ]), ]); console.log(''); console.log( `Celkem ${total.requests} pozadavku za ${total.seconds} s = ${total.requestsPerSec} req/s, ` + `chyb ${total.errors} (${total.errorRate} %)`, ); console.log( `SSE: ${streams.opened} spojeni z ${streamCount}, prijato ${streams.events} udalosti, chyb ${streams.errors}`, ); if (memory) console.log( `Pamet serveru (RSS): max ${memory.rssMaxMb} MB, na konci ${memory.rssLastMb} MB`, ); else console.log('Pamet serveru: /health ji nevystavuje, jen cisla z klienta'); if (webhook) { console.log( `Webhook: ${webhook.accepted} prijato, ${webhook.rejected} odmitnuto, odeslano za ${webhook.sendMs} ms, ` + `fronta prazdna za ${webhook.drainMs} ms = ${webhook.runsPerSec} behu/s ` + `(hotovo ${webhook.done}, selhalo ${webhook.failed}, zbyva ${webhook.pendingAtEnd})`, ); } if (errorKinds.size > 0) { console.log('Chyby:'); for (const [kind, count] of errorKinds) console.log(` ${count}x ${kind}`); } const reportPath = path.join(process.cwd(), REPORT_FILE); await fs.writeFile( reportPath, JSON.stringify( { at: new Date().toISOString(), url: base, local: local !== null, users: settings.users, durationSec: settings.durationSec, endpoints: perEndpoint, total, sse: { ...streams, wanted: streamCount }, server: { memory: memory ?? null, health: 'bez pameti, jen uptime' }, webhook, errors: Object.fromEntries(errorKinds), }, null, 2, ), 'utf8', ); console.log(`[load] zprava zapsana do ${reportPath}`); } finally { if (local) portFreed = await local.stop(); } if (!portFreed) process.exitCode = 1; } main().catch((err) => { console.error('[load] selhalo:', err instanceof Error ? (err.stack ?? err.message) : err); process.exit(1); });