A sketch
A concrete proposal, so that the idea can be argued with rather than admired. Nothing here is implemented, and the open questions at the bottom are the honest part of the page.
The shape
Section titled “The shape”Three files, and the separation between them is the entire idea.
workload.yaml — the model. Reviewable by a product owner, an SRE and a test
manager. This is what the whole exercise exists to make readable.
journeys/*.yaml — what each journey does, in domain steps rather than HTTP
calls.
steps/ — the mechanics, in ordinary code. Each domain step is a function
that makes requests, correlates tokens and extracts values. Written once by
someone who enjoys it, changed when the API changes, and never read by anyone
reviewing the model.
The model
Section titled “The model”workload: checkout-peakdescription: Black Friday morning, from last year's access logs
target: environment: performance base_url: ${PERF_BASE_URL}
# Arrival rate, not concurrent users. Users arriving per second models# traffic; concurrency is a consequence of traffic and of the system's own# latency — fixing it hides the thing being measured.load: shape: ramp arrival_rate: 5/s ramp_to: 50/s over: 10m hold: 30m
# Proportions, because that is how traffic is described in conversation.# Must sum to 100.mix: - journey: browse share: 70% - journey: search share: 20% - journey: checkout share: 10%
# Declared, not a sleep() buried in a function.think_time: distribution: uniform between: [3s, 8s]
data: customers: from ./data/customers.csv cycling products: from ./data/products.csv random
# The SLO, expressed where the run can enforce it. k6's best idea.thresholds: - p95(duration) < 300ms - p99(duration) < 1s - error_rate < 0.1% - journey(checkout).p95(duration) < 2s
# Discarded explicitly rather than averaged in silently.warmup: 2mTwenty-five lines that a non-specialist can read and correct. That is the test from why a performance DSL: if a product owner would not read this file and say “search is more like 35%”, the exercise has failed.
A journey
Section titled “A journey”journey: checkoutsteps: - given: a signed-in customer # from the customers data set - browse the catalogue - add a product to the basket # from the products data set - go to checkout - pay with a test card - expect: an order confirmationDomain steps, not HTTP calls. Deliberately close to Gherkin, because the same journey is likely already written in the end-to-end suite — and a shared vocabulary between the two is worth more than either file individually.
The mechanics
Section titled “The mechanics”export const steps = { 'add a product to the basket': async (ctx) => { const product = ctx.data.products.next(); const res = await ctx.http.post('/basket/items', { productId: product.id, quantity: 1, }); ctx.check(res.status === 201); ctx.vars.basketId = res.json().basketId; // correlation lives here },};Ordinary code, in the repository, tested and reviewed like any other. The important property is that changing an endpoint touches this file and not the model — which is exactly what does not happen today.
Compilation
Section titled “Compilation”The model compiles to a runner. k6 first, because it is the smallest step and
because its options object and thresholds already map almost one-to-one.
perfdsl compile workload.yaml --target k6 > workload.k6.jsperfdsl run workload.yaml --target k6The compiled script is a normal k6 script: readable, debuggable, and runnable by hand when something is wrong. That is a hard requirement, not a convenience. The failure mode of every generated-code tool is the day the generated artefact misbehaves and nobody can work with it; an opaque intermediate would make this tool worse than the thing it replaces.
Why these decisions
Section titled “Why these decisions”Arrival rate over virtual users. The most common modelling error in performance testing is fixing concurrency. Concurrency is an output — if the system slows down, real users keep arriving at the same rate and concurrency rises, which is precisely the effect worth observing. A fixed-VU test hides it.
Proportions over parallel profiles. Nobody says “ramp 140 users on browse and 40 on search”. They say “70% browsing”. The model should use the second sentence.
Think time as a declared property. It has an enormous effect on the load generated, and burying it in a function makes that effect invisible to a reviewer. See performance.
Thresholds inside the model. They are the SLO, they belong with what they constrain, and putting them in the same file means the pass condition is reviewed alongside the workload.
YAML. Not because it is pleasant, but because the model is data — so it can be generated from access logs, diffed in a pull request, and validated by a schema. A custom syntax would need a parser, an editor plugin and error messages before it did anything the format above does not.
The open questions
Section titled “The open questions”None of these are answered. They are what would have to be resolved before any implementation.
1. Where is the escape hatch, and does it destroy the property? Some scenario will need a conditional, a loop, a WebSocket, or a computed think time. If the answer is “write that journey in the target language”, the model file stops describing the whole workload — which is the failure mode of every DSL over a general-purpose tool.
2. Does compilation survive debugging? When the generated k6 script fails, someone reads it and finds a mapping error. Is the generated code good enough to be worked with, and is the round trip back into the model tolerable?
3. Is portability real or theoretical? Compiling the same model to Gatling means mapping arrival profiles, think-time distributions and threshold semantics onto a different engine. If the numbers differ between targets, portability is a liability rather than a feature.
4. Who owns it? A language is a product — versioning, docs, error messages, a maintainer — for a team that runs performance tests twice a release. That arithmetic may simply not work.
5. Is the step vocabulary shared with the E2E suite, or duplicated? Sharing is the more valuable answer and much the harder one: the two runtimes differ, so “the same step” means two implementations behind one name, which is a source of drift rather than a saving.
6. Would a convention have been enough? Artillery plus a rule forbidding inline JavaScript gets a substantial share of this for zero build cost. Any proposal has to beat that, and today it is not obvious that this one does.
Next step: take three real performance scripts, express each as a model file by hand, and count what does not fit. That answers question 1 with evidence rather than speculation, and question 6 falls out of it — because if the hand translation is easy, the convention is the answer.