UNPKG

tressi

Version:

A lightweight, declarative stress testing CLI for modern developers.

354 lines (321 loc) 12.4 kB
import pkg from '../package.json'; import { TressiConfig } from './config'; import { RunOptions } from './index'; import { Runner } from './runner'; import { RequestResult } from './stats'; import { getStatusCodeDistributionByCategory } from './stats'; export interface ReportMetadata { exportName?: string; runDate?: Date; } export interface EndpointSummary { method: string; url: string; totalRequests: number; successfulRequests: number; failedRequests: number; avgLatencyMs: number; minLatencyMs: number; maxLatencyMs: number; p95LatencyMs: number; p99LatencyMs: number; } export interface GlobalSummary { totalRequests: number; successfulRequests: number; failedRequests: number; avgLatencyMs: number; minLatencyMs: number; maxLatencyMs: number; p95LatencyMs: number; p99LatencyMs: number; actualRps: number; theoreticalMaxRps: number; achievedPercentage: number; duration: number; } export interface TestSummary { tressiVersion: string; global: GlobalSummary; endpoints: EndpointSummary[]; } /** * Analyzes the results of a load test and generates a comprehensive summary. * @param runner The `Runner` instance from the test run. * @param options The original `RunOptions` used for the test. * @returns A `TestSummary` object containing the global and endpoint-specific summaries. */ export function generateSummary( runner: Runner, options: RunOptions, actualDurationSec?: number, ): TestSummary { const histogram = runner.getHistogram(); const endpointHistograms = runner.getEndpointHistograms(); if (histogram.totalCount === 0) { // Return a default summary if no results are available. return { global: { totalRequests: 0, successfulRequests: 0, failedRequests: 0, avgLatencyMs: 0, minLatencyMs: 0, maxLatencyMs: 0, p95LatencyMs: 0, p99LatencyMs: 0, actualRps: 0, theoreticalMaxRps: 0, achievedPercentage: 0, duration: 0, }, endpoints: [], tressiVersion: pkg.version || 'unknown', }; } const { durationSec = 10, rps } = options; const totalRequests = histogram.totalCount; const effectiveDuration = actualDurationSec ?? durationSec; const actualRps = effectiveDuration > 0 ? totalRequests / effectiveDuration : 0; const avgLatency = histogram.mean; const theoreticalMaxRps = rps ? Math.min( (1000 / (avgLatency || 1)) * (options.workers || 10), options.rps || Infinity, ) : 0; const achievedPercentage = rps && theoreticalMaxRps ? (actualRps / theoreticalMaxRps) * 100 : 0; const endpointSummaries = Array.from(endpointHistograms.entries()).map( ([endpointKey, endpointHistogram]): EndpointSummary => { const [method, url] = endpointKey.split(' '); const successfulRequests = runner.getSuccessfulRequestsByEndpoint().get(endpointKey) || 0; const failedRequests = runner.getFailedRequestsByEndpoint().get(endpointKey) || 0; return { method, url, totalRequests: successfulRequests + failedRequests, successfulRequests, failedRequests, avgLatencyMs: endpointHistogram?.mean || 0, minLatencyMs: endpointHistogram?.minNonZeroValue || 0, maxLatencyMs: endpointHistogram?.maxValue || 0, p95LatencyMs: endpointHistogram?.getValueAtPercentile(95) || 0, p99LatencyMs: endpointHistogram?.getValueAtPercentile(99) || 0, }; }, ); return { global: { totalRequests, successfulRequests: runner.getSuccessfulRequestsCount(), failedRequests: runner.getFailedRequestsCount(), avgLatencyMs: avgLatency, minLatencyMs: histogram.minNonZeroValue, maxLatencyMs: histogram.maxValue, p95LatencyMs: histogram.getValueAtPercentile(95), p99LatencyMs: histogram.getValueAtPercentile(99), actualRps, theoreticalMaxRps, achievedPercentage, duration: effectiveDuration, }, endpoints: endpointSummaries, tressiVersion: pkg.version || 'unknown', }; } /** * Generates a Markdown report from a summary object. * @param summary The `TestSummary` object. * @param options The original `RunOptions` used for the test. * @param runner The `Runner` instance from the test run. * @param config The `TressiConfig` used for the run. * @param metadata Additional metadata for the report. * @returns A Markdown string representing the report. */ export function generateMarkdownReport( summary: TestSummary, options: RunOptions, runner: Runner, config: TressiConfig, metadata?: ReportMetadata, ): string { const { global: g, endpoints: e } = summary; const distribution = runner.getDistribution(); const { workers = 10, durationSec = 10, rps, autoscale } = options; let md = `# Tressi Load Test Report\n\n`; md += `| Metric | Value |\n`; md += `|---|---|\n`; md += `| Version | ${summary.tressiVersion} |\n`; if (metadata?.exportName) { md += `| Export Name | ${metadata.exportName} |\n`; } if (metadata?.runDate) { md += `| Test Time | ${metadata.runDate.toLocaleString()} |\n`; } md += `\n`; const warnings: string[] = []; if (rps && g.achievedPercentage && g.achievedPercentage < 80) { const maxRpsPerWorker = 1000 / g.avgLatencyMs + 1; const suggestedWorkers = Math.ceil(rps / maxRpsPerWorker) + 1; // at least 2 warnings.push( `**Target RPS Unreachable**: The target of ${rps} RPS was not met. The test achieved ~${g.actualRps.toFixed( 0, )} RPS (${g.achievedPercentage.toFixed( 0, )}% of the target). Based on the average latency of ${g.avgLatencyMs.toFixed( 0, )}ms, you would need at least **${suggestedWorkers}** workers to meet the target.`, ); } for (const endpoint of e) { const failureRate = (endpoint.failedRequests / endpoint.totalRequests) * 100; if (failureRate > 10) { warnings.push( `**High Failure Rate**: The endpoint \`${endpoint.url}\` had a failure rate of ${failureRate.toFixed( 1, )}%. This may indicate a problem under load.`, ); } } if (warnings.length > 0) { md += `## Analysis & Warnings ⚠️\n\n`; md += `> *This section highlights potential performance issues or configuration problems detected during the test.*\n\n`; for (const warning of warnings) { md += `* ${warning}\n`; } md += `\n`; } md += `<details>\n`; md += `<summary>View Full Test Configuration</summary>\n\n`; md += '```json\n'; md += `${JSON.stringify(config, null, 2)}\n`; md += '```\n\n'; md += `</details>\n\n`; // Run Configuration md += `## Run Configuration\n\n`; md += `> *This table shows the main parameters used for the load test run.*\n\n`; md += `| Option | Setting | Argument |\n`; md += `|---|---|---|\n`; md += `| Workers | ${autoscale ? `Up to ${workers}` : workers} | \`--workers\` |\n`; md += `| Duration | ${durationSec}s | \`--duration\` |\n`; if (rps) md += `| Target Req/s | ${rps} | \`--rps\` |\n`; if (autoscale) md += `| Autoscale | Enabled | \`--autoscale\` |\n\n`; // Global Summary md += `## Global Summary\n\n`; md += `> *A high-level overview of the entire test performance across all endpoints.*\n\n`; md += `| Stat | Value |\n| --- | --- |\n`; md += `| Duration | ${g.duration.toFixed(0)}s |\n`; md += `| Total Requests | ${g.totalRequests} |\n`; md += `| Successful | ${g.successfulRequests} |\n`; md += `| Failed | ${g.failedRequests} |\n`; if (options.rps && g.theoreticalMaxRps) { md += `| Req/s (Actual/Target) | ${g.actualRps.toFixed(0)} / ${options.rps} |\n`; md += `| Req/m (Actual/Target) | ${(g.actualRps * 60).toFixed(0)} / ${options.rps * 60} |\n`; md += `| Theoretical Max Req/s | ${g.theoreticalMaxRps.toFixed(0)} |\n`; md += `| Achieved % | ${g.achievedPercentage.toFixed(0)}% |\n`; } else { md += `| Req/s | ${g.actualRps.toFixed(0)} |\n`; md += `| Req/m | ${(g.actualRps * 60).toFixed(0)} |\n`; } md += `| Avg Latency | ${g.avgLatencyMs.toFixed(0)}ms |\n`; md += `| Min Latency | ${g.minLatencyMs.toFixed(0)}ms |\n`; md += `| Max Latency | ${g.maxLatencyMs.toFixed(0)}ms |\n`; md += `| p95 Latency | ${g.p95LatencyMs.toFixed(0)}ms |\n`; md += `| p99 Latency | ${g.p99LatencyMs.toFixed(0)}ms |\n\n`; // Latency Distribution if (distribution.getTotalCount() > 0) { const distributionResult = distribution.getLatencyDistribution({ count: 8, chartWidth: 20, }); md += `## Latency Distribution\n\n`; md += `> *This table shows how request latencies were distributed. **% of Total** is the percentage of requests that fell into that specific time range. **Cumulative %** is the running total, showing the percentage of requests at or below that latency.*\n\n`; md += `| Range (ms) | Count | % of Total | Cumulative % | Chart |\n`; md += `|---|---|---|---|---|\n`; for (const bucket of distributionResult) { if (bucket.count === '0') continue; md += `| ${bucket.latency}ms | ${bucket.count} | ${bucket.percent} | ${bucket.cumulative} | ${bucket.chart} |\n`; } md += `\n`; } // Error Summary if (g.failedRequests > 0) { md += `## Error Summary\n\n`; md += `> *A total of ${g.failedRequests} requests failed. Detailed error messages are available in the raw log (if exported).*\n\n`; } // Status Code Summary const statusCodeMap: Record<number, number> = runner.getStatusCodeMap(); if (Object.keys(statusCodeMap).length > 0) { const statusCodeDistribution = getStatusCodeDistributionByCategory(statusCodeMap); md += `## Responses by Status Code\n\n`; md += `> *A breakdown of all responses by their HTTP status code categories.\n\n`; md += `| Status Code Category | Count |\n`; md += `|---|---|\n`; for (const [category, count] of Object.entries(statusCodeDistribution)) { md += `| ${category} | ${count} |\n`; } md += `\n`; } const sampledResults = runner.getSampledResults(); const sampledResponses = sampledResults.filter((r) => r.body); if (sampledResponses.length > 0) { md += `## Sampled Responses by Endpoint\n\n`; md += `> *A sample response body for each unique status code received per endpoint. This is useful for debugging unexpected responses.*\n\n`; const samplesByEndpoint = sampledResponses.reduce( (acc, r) => { const key = `${r.method} ${r.url}`; if (!acc[key]) { acc[key] = []; } acc[key].push(r); return acc; }, {} as Record<string, RequestResult[]>, ); for (const [endpoint, samples] of Object.entries(samplesByEndpoint)) { md += `#### \`${endpoint}\`\n\n`; const uniqueSamples = new Map<number, RequestResult>(); for (const r of samples) { if (!uniqueSamples.has(r.status)) { uniqueSamples.set(r.status, r); } } Array.from(uniqueSamples.values()) .sort((a, b) => a.status - b.status) .forEach((r) => { md += `<details>\n`; md += `<summary><strong>${r.status}</strong></summary>\n\n`; md += '```\n'; md += `${r.body || '(No body captured)'}\n`; md += '```\n\n'; md += `</details>\n`; }); md += `\n`; } } // Endpoint Summary if (e.length > 0) { md += `## Endpoint Summary\n\n`; md += `> *A summary of request outcomes for each endpoint.*\n\n`; md += `| Endpoint | Success | Failed |\n`; md += `|---|---|---|\n`; for (const endpoint of e) { md += `| ${endpoint.method} ${endpoint.url} | ${endpoint.successfulRequests} | ${endpoint.failedRequests} |\n`; } md += `\n`; md += `## Endpoint Latency\n\n`; md += `> *A detailed latency breakdown for each individual API endpoint.*\n\n`; md += `| Endpoint | Avg | Min | Max | P95 | P99 |\n`; md += `|---|---|---|---|---|---|\n`; for (const endpoint of e) { md += `| ${endpoint.method} ${endpoint.url} | ${endpoint.avgLatencyMs.toFixed(0)}ms | ${endpoint.minLatencyMs.toFixed(0)}ms | ${endpoint.maxLatencyMs.toFixed(0)}ms | ${endpoint.p95LatencyMs.toFixed(0)}ms | ${endpoint.p99LatencyMs.toFixed(0)}ms |\n`; } } return md; }