Imported from OctoPerf/octoperf-claude-plugins (
plugins/octoperf/skills/octoperf-bench-reports/SKILL.md). Install upstream withnpx skills add OctoPerf/octoperf-claude-plugins --skill octoperf-bench-reports. Copyright stays with the author.
OctoPerf — Reading bench reports
A BenchReport is a polymorphic document. Its items array carries
20+ report item types (charts, tables, top-N, insights, …), each backed by
its own get_report_*_values tool. This skill maps every report item you
can encounter to the right tool, calls out the semantic gotchas
that have repeatedly tripped LLMs, and explains the trend-report
architecture.
Editing a report item's metrics/filters (not just reading them)? See
octoperf://skills/report-item-editing— it covers the allow-select workflow (get_report_item_allow_select→get_report_tag_keys/get_report_tag_values→validate_report_item→patch_bench_report).
The BenchReport shape — one quick anchor
BenchReport {
id, projectId, name, benchResultIds, // — the runs the report aggregates
configs: [ApdexReportConfig | TrendReportConfig | ...], // global settings
items: [polymorphic BenchReportItem...] // — what's visible on the page
}
- A regular report has 1 entry in
benchResultIds(the run it was generated for) and items that pull values from that run. - A trend report has 1 entry too (the reference anchor) and a
TrendReportConfiginconfigswhoseselectorsare re-evaluated dynamically at read time to pull in other matching runs. See Trend reports below.
To read any report item, always start with:
mcp__octoperf__get_bench_report(reportId)
then dispatch on each items[i]["@type"] per the table below.
Before reading: the run's data must be up to date
A bench result holds its samples in the shape the current analysis queries expect only once its data has been updated. A run recorded before the last data model change still holds the previous shape, and the metric queries never look there: every report item answers empty — not "old numbers", not an error. Zeros read as a finding, which is how an outdated run becomes a fabricated diagnosis.
So the report tools refuse rather than answer empty:
| tool | on data that is not up to date |
|---|---|
the 12 get_report_*_values, plus get_report_insights / _errors / _threshold_alarms / _textual_monitors |
refuse, naming the runs concerned — per report item, so only the runs that report item actually reads |
get_report_tag_keys / get_report_tag_values |
refuse — an empty tag set reads as "this run has no action, no region" |
export_bench_report_pdf |
refuse — a PDF of empty report items is a document the user forwards |
list_bench_reports_by_project |
answers, carrying reportIdsNeedingDataUpdate / reportIdsBeingUpdated |
create_trend_report_by_name / _by_tags / _by_creation_date |
answer, carrying the project's runs the trend will not be able to plot |
create_comparison_report |
answers, carrying the compared runs whose column would read empty |
get_report_item_allow_select, validate_report_item, patch_bench_report, get_bench_report |
answer as usual — editing a report item reads its schema, not samples |
| the seven report-template tools | answer as usual — a template is a layout, it holds no samples |
When a read refuses:
get_report_data_status(reportId)— sorts the report's runs intoupToDate/needsUpdate/updating/unknown(unknown= the run could not be read at all, usually deleted).- Ask the user first. Updating rewrites the run's stored data, takes minutes on a large run, and is not reversible.
update_report_data(reportId)— launches one update per run that needs it and returns ataskIdfor each.- Poll each task with
get_task_result(seeoctoperf-async-polling), then re-read the report item.
A task that ends FAILED on a N problem(s) met while migrating…
message updated what it could and left the rest behind: the run then
counts as up to date while the report items that depended on the missing
part still read empty. Pass that message to the user verbatim — it
names what was left out — and do not re-run the update expecting a
different outcome.
Two rules when talking to the user:
- A refusal is not "the test recorded nothing" — say the report's data needs updating first, and name the runs.
- Never call it a migration. The report's data is being updated.
Report item → tool mapping
For every report item type that's reachable from MCP:
@type |
Tool | Returns |
|---|---|---|
SummaryReportItem |
get_report_summary_values |
List<Double> aligned with item.metrics[i].id |
BarChartReportItem |
get_report_summary_values (same shape as Summary) |
List<Double> aligned with item.metrics[i].id |
StatisticTableReportItem |
get_report_table_values |
List<TableEntry> (actionId → values) |
StatisticTreeReportItem |
get_report_tree_values |
List<TreeEntry> (virtualUserId + actionId → values) — per-VU split |
TopReportItem |
get_report_top_values |
TopResult (top-N actionIds + per-action curve) |
PieChartReportItem |
get_report_pie_values |
List<Map<String, Long>> (one map per benchResult, label → count) |
LineChartReportItem |
get_report_line_chart_values |
List<List<GraphPoint>> (one series per metric, (x=epoch-ms, y)) |
PercentilesChartReportItem |
get_report_line_chart_values |
Same shape — percentile curve |
StackedChartReportItem |
get_report_stacked_chart_values |
List<MapGraphPoint> (x + per-series map) |
AreaRangeChartReportItem |
get_report_area_range_values |
AreaRangeResult (curve vs reference, rmse) |
InsightsReportItem |
get_report_insights |
Set<Insight> (severity + value + drill-in report item) |
ErrorsReportItem |
get_report_errors |
List<BenchError> (per-sample failures) |
ThresholdAlarmReportItem |
get_report_threshold_alarms |
List<ThresholdAlarm> (per-breach) |
TextualMonitorReportItem |
get_report_textual_monitors |
List<TextualCounterValue> (string-valued monitor samples) |
LoadGeneratorsChartReportItem |
list_bench_load_generators |
List<BenchLoadGenerator> — chart is derived from this |
LoadGeneratorsTreeReportItem |
list_bench_load_generators |
Same source as the chart — tree is just a different rendering |
TextReportItem |
(no tool — descriptive markdown) | n/a — item.description carries the markdown |
SynopsisReportItem |
(no tool — scenario metadata) | n/a — render the synopsis section in the UI for the user |
TrendConfigReportItem |
(no tool — read configs) |
n/a — the selectors live in the report's TrendReportConfig |
MonitorsTableReportItem |
get_report_monitors_table_values |
One row per monitor connection of the run: monitorType, counterPaths, alarmCount, worst severity |
Two follow-up tools to keep in mind:
- After
get_report_errors, drill into a specific failed sample withfetch_bench_error_http(benchResultId, actionId, timestamp)— returns the full request + response of that one breach. Both tools hand back text: a Playwright failure is stored with the colors its runner produced — inerrorMessageand, since the body is the error there, in the response body too — and the two tools drop them. The web UI renders them instead, so a screenshot shows colors the tool never returns. - For non-text bench-result artefacts (Playwright
trace.zip, screenshots, HAR),download_bench_result_file(benchResultId, filename)returns a presigned GET URL (single-use, ~5 min) — fetch the bytes directly with your code interpreter.read_bench_result_file_linesonly handles text.
Semantic gotchas
A field-collected list of values that look like one thing but mean another. Each cost an LLM debug cycle in the past — surface them to the user when reading the data:
Hits vs Hits (CONTAINER)
Hits(and its ratesHits/s,Hits successful total) count HTTP samplers only.Hits (CONTAINER)counts everything else — containers, logic actions (Loop / If / While), JMeter plugins, the VU root container.
When get_report_top_values returns a top-by-avg-RT where the highest
row is the VU's root container (no parent in the action tree),
the value is the whole iteration's wall-clock — including thinktime.
Ignore the container row when looking for slow real actions.
Metric subtypes — not every sub-count is on every report item
For the full per-report item allow-list, see the hit-metrics availability table in the public doc. The recurring picks that trip up an LLM:
Hits(Total/Total Successful/Rate/% Successful) andErrors(Total/Rate/% Error) are accepted on Line, Summary, Table/Tree, Bar, Area.TopexcludesRatefor both;Percentilesaccepts onlyTotal+Ratefor Hits and onlyTotal+Ratefor Errors (no% Error).Errors % Erroris on a 0..100 scale, so Insight thresholds expressed as integers in 0..100 compare to it directly.Median(RESPONSE_TIME_MEDIAN) is on Summary / Table / Tree / Bar only — not on Line, Top, Percentiles or Area.- The discrete percentiles
RESPONSE_TIME_PERCENTILE_80 / 90 / 95 / 99live on Line / Summary / Top / Table / Tree / Bar / Area. ThePercentilesChartReportItemreport item plots a continuous percentile curve from a base metric (Response Time, Latency, …) and does not accept these discrete percentile sub-counts as metrics — picking one for a Percentiles report item is a mismatch. Apdexis defined onResponse Time / Connect Time / Latencyonly, on Line, Summary, Table, Tree, Bar, Area — never on Top or Percentiles. It requiressatisfying+toleratingthresholds, falling back to the globalApdexReportConfigon the report when unset on the metric.Network Time = Response Time − Latency— pre-computed server-side; the value is real even if noLatencycurve appears in the report. NoStdDevorApdexvariant exists.Received Dataonly supportsTotalandRate;Sent DataaddsAverage / Min / Max / StdDev / Total / Rate. Asking forReceived Data Averagereturns nothing.UserLoadis a monitor sample (not a hit metric). It shows up as the load-curve overlay on Line / Bar / Area charts but isn't selectable through the same picker as hit metrics.HTTP methods/HTTP response codes/Media types count/Media types throughputonly appear onPieChartReportItemandStackedChartReportItem— they're not in the hit-metrics availability table.
Cache hits (304s) skew global numbers
JMeter's CacheManager is on by default. On any VU that revisits
the same URL within a session, the server returns HTTP 304 Not
Modified and JMeter records the sample. The response time +
throughput then reflect a cache check, not real load on the SUT.
→ If get_report_pie_values on the response-codes pie shows more
than ~40% 304s, flag it when summarising: the visible numbers
are an optimistic floor; the real SUT cost lives in the 200 samples.
Playwright per-step row types (StatisticTree)
The same VU can emit many row types in get_report_tree_values,
keyed by actionId with a JSON-encoded suffix:
type (in the label suffix) |
What it measures |
|---|---|
| (bare actionId, no suffix) | Wall-clock per spec iteration — source of truth for UX |
GROUP (label="Actions") |
Sum of all ACTION durations |
GROUP (label="Network") |
Cumulative time in HTTP requests per iteration |
HOOK (Before/After Hooks) |
Playwright setup / teardown |
ACTION (page.X(...)) |
Single Playwright command duration |
EXPECT (expect.X(...)) |
Single assertion duration |
NETWORK (<host>) |
Aggregate of every HTTP request the browser made |
NAVIGATION |
DOM ready / load timing |
Cardinal rule: don't sum types — they overlap because Playwright is
async. If Network GROUP says 2.5 s and Actions GROUP says 1.5 s,
the per-iteration wall-clock is not 4 s. Read the bare actionId
row for the true wall-clock.
Quirks by report item type
StackedChartReportItemaccepts exactly one metric — the toggle ismode: ABSOLUTE | PERCENT. Multi-metric stacked configs are not representable.AreaRangeChartReportItem:referenceType = HISTOGRAM(time-varying reference) orSUMMARY(constant average);rangeType = RAW(both metrics share a unit) orPERCENTAGE(mixed-unit comparison). Wrong combinations return a meaningless curve, not an error.InsightsReportItememits a "not enough data" notice when the run has <50 VUs or <20 minutes — insights on shorter/smaller runs can be ignored or hedged.- Insight thresholds are percentages in 0..100 (not 0..1) — they govern the severity bucket (Passed / Info / Warn / Error). The same heuristic value can map to a different severity depending on the per-config thresholds.
StatisticTableReportItem/StatisticTreeReportItem: if the source VU hasdownloadResources=true, every HTTP request action produces two rows — the request itself plus a.resourcesrow aggregating all embedded assets. The.resourcesrow's hit count = total embedded sub-requests, not iterations.ErrorsReportItem(get_report_errors): on SaaS the result is capped at 2 rows per(loadGenerator, request, responseCode)triple — counts are exact but the returned sample list is a quota-limited subset (on-prem can override).ErrorsReportItemcovers 3 trigger types: 4XX/5XX, engine-level Java exception (response code-1, headerHTTP/1.1 -1 - UNKNOWN), and failedResponseAssertion. Only theassertions[]field on aBenchErrordistinguishes assertion failures from non-2XX.TextualMonitorReportItem: filter is monitor-connection-only (no metric / location filters). An empty result means the connection emitted no string-valued counters this run.MonitorsTableReportItemlists the run's own copy of the monitor connections — SLA profiles included, load generator hosts and JVMs excluded since the report charts those inLoadGeneratorsChartReportItemitems. ItsconnectionName/counterPathsare tag values, ready to use asSingleTermFilterterms on aMONITORINGmetric. An emptycounterPathsmeans the connection was configured but sampled nothing.LoadGeneratorsChartReportItem(hosts:monitorType=HOST; JVMs:monitorType=JVM) plots a fixed metric set as max across all LGs, not per-LG. Hosts:%CPU,%Mem,%SegRetrans,Received MB/s. JVMs: heap %, G1 young/old count + time.
parallelRunsSupported in ScenarioMatchingPlan
When get_scenario_matching_plans returns plans with
parallelRunsSupported, that integer is the number of simultaneous
instances of the scenario the plan can host (typically 1 — only
matters with maxTestsPerRun > 1). It is not "max users the plan
will allocate". Any non-empty result means the run is launchable as
configured.
KO matrix can be overridden by a ResponseAssertion
get_report_errors will return KO samples that look like 200/200 in
the recorded matrix — that's a ResponseAssertion firing on the
body. Check assertions on the BenchError before assuming HTTP
mismatch. Useful pointer:
octoperf-validation-triage for the
full KO/OK matrix + assertion override.
Insight value ≡ AreaRange rmse
When an InsightsReportItem fires with severity ERROR/WARN, its
value is the same number as the rmse of the
AreaRangeChartReportItem report item linked from its inspect. They're
the same heuristic exposed twice. Don't fetch both unless you want
to render the curve+reference visually.
Trend reports
A trend report compares the anchor benchResult (the one in
benchResultIds[0]) against a dynamically-resolved list of other
benchResults from the project. The matching is defined by the
TrendReportConfig inside the report's configs:
{
"@type": "TrendReportConfig",
"selectors": [{
"@type": "TrendReportNameSelector" | "TrendReportTagsSelector" | "TrendReportCreationDateSelector",
...
}],
"shownResults": 20
}
Three selector types correspond to the three creation tools:
create_trend_report_by_name—TrendReportNameSelectormatching the scenario name (EQUALS / CONTAINS / STARTS_WITH / ENDS_WITH, with_IGNORECASEvariants).create_trend_report_by_tags—TrendReportTagsSelectormatching the tag intersection on bench results.create_trend_report_by_creation_date—TrendReportCreationDateSelectormatching a[fromMs, toMs]window.
The list is recomputed on every report read. A run created after
the trend's created timestamp will appear on the next read if
it matches the selector — you don't need to recreate the report.
Caps and the Reference Test. A trend report holds at most 25 matched results plus one Reference Test that cannot be unselected. The Reference Test is preserved past the project's default 100-result retention cap, and deleting it is blocked while a trend report still uses it. Manual labels (Trend Manual Selection) override the auto-generated bench names and live on the trend config, not on the bench results themselves.
What changes in a trend report's items
Three report item types behave differently in a trend context:
TrendConfigReportItem— read-only display of the selectors. Use the report'sconfigsdirectly.StatisticTableReportItemwithcomputeType: "DELTA"— the table shows the delta of each metric between the anchor and each matched run. Negative = improvement, positive = regression. Useget_report_table_valuesas usual; the diff math happens server-side.BarChartReportItemtitled "Latest vs Reference Summary" — one bar per matched run for each metric. Sameget_report_summary_valuescall, just more values returned.
The other report items (LineChart, AreaRange, Pie, …) work the same; they just plot the anchor by default.
Comparison reports
A comparison report holds 2 to 4 bench results labelled Run A /
B / C / D by default in report item legends. Unlike trend
reports, the result list is static — it's the snapshot in
benchResultIds, not a selector re-evaluated on read. To follow one
scenario over time, that's a trend report; to put a handful of named
runs side by side, it's this.
Creating one: create_comparison_report(benchResultIds, names?).
The backend lays out the whole report from its own template — the
shared report items (user loads, statistics summary, comparison
table, response-code donut, response-time chart) each carry one
metric per run, followed by a detail section per run. Nothing has
to be duplicated by hand afterwards.
benchResultIdsis the column order. Index 0 is the first column. The Compare page sorts oldest-first before creating, which is the convention to follow unless the user says otherwise.nameslabels the columns positionally — one per run, or leave it out for the automaticRun A…Run D. The labels live in the report'sComparisonReportConfig, never on the bench results themselves, so renaming a column changes this report only.- Every run must be over (FINISHED or ABORTED; a run in ERROR produced nothing) and in the same project — the report is filed under the project of the first run.
- Adding a fifth run later is not a thing: create another report.
Reading one: the TextReportItem descriptions of a comparison
report carry $RESULTS$ and $RESULT(0)$ markers. They are
placeholders the web UI substitutes at render time — through MCP
they come back raw. Resolve them yourself from benchResultIds (or
just describe the section) rather than showing a user a $RESULT(0)$.
The same goes for $COMPANY_NAME$, $REPORT_NAME$, $REPORT_DATE$,
$PROJECT_NAME$ and $SLA_PROFILES(resultId)$. A PDF export renders
through the UI, so it shows the resolved text.
Report templates
A report template is a saved report layout — the report items and settings of a report, with the run they were read from taken out — so that the next run's report can be given the same shape in one call. It is a workspace entity: every project of the workspace sees the same list. In the UI: Analysis → Report Templates.
| Tool | What it does |
|---|---|
list_report_templates(projectId) |
the templates of that project's workspace — id, name, description, itemCount, url |
get_report_template(projectId, templateId) |
the template in full: its items and configs, the "Report Contents" of the UI |
create_report_template_from_report(reportId, name?, description?) |
saves an existing report as a template; the report is untouched |
patch_report_template(projectId, templateId, patch) |
RFC 6902 JSON Patch over the template — its report items, its per-metric colours and Apdex, its settings |
apply_report_template(reportId, templateId) |
replaces a report's items with the template's, repointed at that report's run |
update_report_template(projectId, templateId, name?, description?) |
renames it |
delete_report_template(templateId) |
drops the layout; the reports built from it keep their items |
A template is also what a run's report can be built from in the first
place: run_scenario(scenarioId, templateId) and
schedule_scenario_once / _cron with a templateId give the new run
the template's shape instead of the default layout — the Template
picker of the launch page and of the scheduler. That is one call
instead of run-then-apply, and it is the only way for a scheduled job,
which fires with nobody there to apply anything.
A template is not a copy of the report. On the way in, filters that
only mean something inside the run the report was built on are
stripped: the load generator that served a request (injectorId), the
region it ran from (region), and the monitoring connections the run
created for itself (the load generators' own host and JVM
connections). A metric whose filters all disappear is dropped, and a
report item left without a single metric is dropped with it. That is
why create_report_template_from_report answers with sourceItemCount
and runSpecificFilterFields next to the template's own itemCount —
say what did not make it in rather than presenting the template as
faithful.
Applying one replaces the report's items, and the previous layout
is not recoverable. Only the report's own time range survives; the
template's ${PROJECT_NAME}, ${REPORT_NAME}, ${PROJECT_DESCRIPTION}
and ${REPORT_DESCRIPTION} placeholders are filled in on the way.
apply_report_template refuses on a report that compares several runs:
the mechanism repoints every report item at benchResultIds[0], so a
comparison report would come back with a single column.
Editing a template. patch_report_template writes the same fields
the Report Templates page edits. Where presentation lives:
- a metric's colour — a
SingleColorReportConfigin that metric's ownconfigs, e.g./items/2/metrics/1/configs/-with{"@type":"SingleColorReportConfig","color":"#CC5500"}; - a metric's Apdex — an
ApdexReportConfigin the same place; - a metric's Y axis — an
AxisReportConfigin the same place, e.g.{"@type":"AxisReportConfig","scale":"LOGARITHMIC","dedicated":true}.scaleisLINEARorLOGARITHMIC(LINEARwhen absent);dedicated: truetakes the metric off the axis it shares with the metrics of its unit and gives it one of its own. The scale belongs to the axis but is stored per metric: write the samescaleon every metric of one axis. Only line charts, unstacked bar charts and percentiles charts draw it, every other report item ignores it; - the template-wide settings — the root
/configs.
Two things it will not do. It refuses a patch that changes id or
workspaceId. And, unlike patch_bench_report, it cannot check a
metric against a run's allow-select descriptor — a template has no run
to resolve one from, so an impossible metric surfaces only once the
template is applied. When the change is about which metric rather
than how it looks, shape a real report first and re-extract.
Where templates fit the workflow. The natural loop is: shape one
report the way the user wants it → save it as a template → give it to
the next runs, either up front (run_scenario(templateId),
schedule_scenario_*(templateId)) or after the fact
(apply_report_template). create_report_template_from_report is the
only way to create one — there is no writing a template from scratch.
Report configuration caveats
Time rangefiltering applies to simple reports only (not comparison reports), and only after the run isFINISHED. Applying a time range to a running test is a no-op.- The per-report caps
maxPercentiles/maxColumns/maxPies/maxStatistics/maxLinesare enforced when adding metrics; report items that pre-date the cap keep their over-the-limit metric lists.
Pitfalls
- Don't read zeros as a result. A report item that refuses because its run's data is not up to date is not a run without traffic. See Before reading — and never present the two as the same thing.
- Don't call a
get_report_*_valuestool with the wrong item type. Each tool checks the@typeand rejects unrelated report items withIllegalArgumentException. The error message points at the right tool. - Don't read a half-finished bench's report. If
get_bench_result(benchResultId).stateis not yetFINISHED, the values are partial. Label the read as preliminary and offer to come back once it is.get_bench_statusreturns a 0-100 progress percentage, so it can tell you how far the run has got but never that it is done. - Don't ignore
Trust caveatsfromoctoperf-scenario-diagnosis. Load-generator overload underestimates response times; cache hits skew global numbers. Both surface in the bench report but the numbers don't carry the warning themselves. - A monitors table row counts alarms, it does not describe them.
get_report_monitors_table_valuessays how many alarms a connection raised and the worst severity among them; which threshold fired, when and for how long comes fromget_report_threshold_alarmson the report'sThresholdAlarmReportItem. - Don't hand a user a
$RESULT(0)$. The markers in a comparison report's texts are resolved by the web UI, not by the tools. Substitute them frombenchResultIdsor describe the section instead. - A template applied is a layout overwritten.
apply_report_templatereplaces the target report's items for good; there is no undo and no previous version to read back. Say so before calling it. - A
ThresholdAlarmwith 0-duration is an instantaneous breach. Non-zero duration means a sustained one. Treat isolated 0-duration alarms as noise; clusters (multiple within seconds) are signal. - Playwright
Results Treerows can show negative response times.page.waitForTimeoutand actions inside nestedfor/ifblocks can produce them in corner cases — don't propagate a negative value as an anomaly upstream.
See also
octoperf-scenario-diagnosis— workflow for diagnosing a poor run (this skill is the reading guide; scenario-diagnosis is the action plan).octoperf-validation-triage— when the report shows the VU itself is failing.- OctoPerf bench reports docs: https://doc.octoperf.com/analysis/bench-reports/
