Artillery Plugins: Metrics, Assertions, and Observability Integrations
Out of the box, Artillery gives you latency percentiles and status code counts. That's a starting point, but for serious load testing you need custom assertions, metrics that go to your observability stack, and hooks to run custom logic during tests. Artillery's plugin system covers all of this.
This post covers the expect plugin for assertions, custom processors for scripting, and the major observability integrations: Datadog, CloudWatch, and Prometheus.
The Expect Plugin
The expect plugin adds response assertions to your scenarios. Without it, Artillery records HTTP errors (5xx, timeouts) but won't fail a test because a 200 response had the wrong body.
Install it:
npm install -g artillery-plugin-expectEnable it in your config:
config:
target: "https://api.example.com"
plugins:
expect: {}
phases:
- duration: 60
arrivalRate: 10Now add assertions to your flow steps:
scenarios:
- name: "User API"
flow:
- get:
url: "/users/1"
expect:
- statusCode: 200
- contentType: json
- hasProperty: "id"
- hasProperty: "email"
- equals:
- "{{ response.body.active }}"
- true
- post:
url: "/users"
json:
name: "Test User"
email: "test{{ $randomNumber(1,9999) }}@example.com"
expect:
- statusCode: 201
- hasProperty: "id"
- matchesRegexp:
- "{{ response.body.email }}"
- "^test[0-9]+@example\\.com$"Available assertions:
| Assertion | What it checks |
|---|---|
statusCode: 200 |
HTTP status code equals value |
statusCode: [200, 201] |
Status code is one of the values |
contentType: json |
Content-Type header contains "json" |
hasProperty: "field" |
Response body has the field (nested: "a.b.c") |
notHasProperty: "field" |
Response body does not have the field |
equals |
Two values are strictly equal |
notEquals |
Two values are not equal |
greaterThan |
Numeric comparison |
lessThan |
Numeric comparison |
matchesRegexp |
Value matches regex pattern |
Expectation failures show in the summary report under "Errors" with a description of which assertion failed and on which URL. They don't cause the VU to abort — the scenario continues to the next step. If you need to abort on failure, use a processor (covered below).
Custom Processors
Processors are JavaScript modules that hook into the Artillery execution lifecycle. You get full Node.js — you can call databases, generate complex data, log to external systems, or implement custom assertion logic.
config:
target: "https://api.example.com"
processor: "./processor.js"
phases:
- duration: 60
arrivalRate: 10
scenarios:
- name: "Custom flow"
flow:
- function: "generatePayload"
- post:
url: "/api/items"
json: "{{ payload }}"
afterResponse: "validateResponse"// processor.js
// Called as a flow step — sets variables for the VU
function generatePayload(context, events, done) {
const items = ['A', 'B', 'C'];
const item = items[Math.floor(Math.random() * items.length)];
context.vars.payload = {
itemType: item,
quantity: Math.floor(Math.random() * 10) + 1,
timestamp: Date.now()
};
return done();
}
// Called after a response is received
function validateResponse(requestParams, response, context, events, done) {
if (response.statusCode === 200) {
const body = JSON.parse(response.body);
if (!body.id) {
events.emit('counter', 'missing_id_in_response', 1);
}
if (body.processingTime > 500) {
events.emit('counter', 'slow_processing', 1);
}
// Store for use in later steps
context.vars.itemId = body.id;
}
return done();
}
module.exports = { generatePayload, validateResponse };Lifecycle Hooks
Processors can hook into the full test lifecycle:
// processor.js
// Runs once before the test starts
function beforeScenario(context, events, done) {
// Set up per-VU state
context.vars.requestCount = 0;
return done();
}
// Runs once after each scenario completes
function afterScenario(context, events, done) {
console.log(`VU completed ${context.vars.requestCount} requests`);
return done();
}
// Runs before each request
function beforeRequest(requestParams, context, events, done) {
context.vars.requestCount = (context.vars.requestCount || 0) + 1;
// Add a request signature header
requestParams.headers['X-Request-Nonce'] = generateNonce();
return done();
}
// Runs after each response
function afterResponse(requestParams, response, context, events, done) {
// Emit a custom metric
const latency = response.timings.phases.total;
events.emit('histogram', 'custom.latency', latency);
return done();
}
function generateNonce() {
return Math.random().toString(36).substring(2, 15);
}
module.exports = {
beforeScenario,
afterScenario,
beforeRequest,
afterResponse
};Reference these in config:
config:
processor: "./processor.js"
hooks:
beforeScenario:
- beforeScenario
afterScenario:
- afterScenario
beforeRequest:
- beforeRequest
afterResponse:
- afterResponseCustom Metrics
Processors emit custom metrics via the events object:
// Counter — increments a named counter
events.emit('counter', 'checkout.success', 1);
events.emit('counter', 'checkout.failure', 1);
// Histogram — records a value distribution (latencies, sizes, etc.)
events.emit('histogram', 'order.item_count', itemCount);
events.emit('histogram', 'response.body_size', response.body.length);
// Rate — events per second
events.emit('rate', 'cache.hit');These appear in Artillery's summary output and are forwarded to any configured integrations (Datadog, CloudWatch, etc.).
Datadog Integration
Send metrics to Datadog during the test run:
npm install -g artillery-plugin-publish-metricsconfig:
target: "https://api.example.com"
plugins:
publish-metrics:
- type: datadog
apiKey: "{{ $processEnvironment.DATADOG_API_KEY }}"
region: "us1"
prefix: "artillery."
tags:
- "env:staging"
- "service:api"
- "test:load"
event:
send: true
title: "Artillery Load Test"
text: "Load test started/stopped"
priority: "normal"
alertType: "info"
phases:
- duration: 300
arrivalRate: 50With this config, Artillery streams metrics to Datadog in real time. You can build dashboards showing:
artillery.latency.p95— p95 response timeartillery.latency.p99— p99 response timeartillery.http.codes.200— count of 200 responsesartillery.http.codes.500— count of 500 responses- Any custom metrics you emit from processors
The event.send: true option creates Datadog events when the test starts and stops — useful for annotating your existing service dashboards so you can correlate load test runs with changes in system metrics (CPU, memory, database query times).
You can cross-reference Artillery's latency metrics against your application's own Datadog metrics. If Artillery shows p95 latency spiking at the same time your database metrics show connection pool exhaustion, you've found your bottleneck.
CloudWatch Integration
For AWS environments:
config:
plugins:
publish-metrics:
- type: cloudwatch
region: "us-east-1"
namespace: "LoadTests"
dimensions:
- name: "Environment"
value: "staging"
- name: "Service"
value: "api"This uses the AWS SDK under the hood. Set credentials via environment variables:
AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... artillery run test.yamlOr if running on EC2/ECS with an IAM role, credentials are picked up automatically.
CloudWatch metrics appear under the namespace you specify. Create CloudWatch alarms on these metrics to alert if load tests push latency above thresholds.
Prometheus / StatsD Integration
For self-hosted observability stacks:
config:
plugins:
publish-metrics:
- type: statsd
host: "localhost"
port: 8125
prefix: "artillery."
tags:
plain: ["env:staging"]StatsD metrics are compatible with most metrics collectors — Prometheus via statsd_exporter, InfluxDB, Graphite. If you're already running a StatsD pipeline, plug Artillery into it.
The CloudWatch Embedded Metrics Plugin
If you're running Artillery in Lambda or want structured CloudWatch Logs rather than CloudWatch Metrics API calls:
config:
plugins:
publish-metrics:
- type: cloudwatch
region: "us-east-1"
namespace: "ArtilleryTests"
sendInterval: 60The sendInterval controls how often metrics are flushed. Lower values give you finer granularity in CloudWatch but increase API call volume.
Writing a Custom Plugin
If the built-in integrations don't cover your stack, write a plugin:
// artillery-plugin-custom-metrics.js
'use strict';
class CustomMetricsPlugin {
constructor(config, events) {
this.config = config;
// Hook into the stats event emitted after each reporting period
events.on('stats', (stats) => {
this.publishStats(stats);
});
// Hook into test completion
events.on('done', (stats) => {
this.publishFinalStats(stats);
this.cleanup();
});
}
publishStats(stats) {
const p95 = stats.latency?.p95;
const p99 = stats.latency?.p99;
const rps = stats.rps?.mean;
// Push to your metrics backend
console.log(JSON.stringify({
timestamp: Date.now(),
p95_latency: p95,
p99_latency: p99,
requests_per_second: rps,
error_count: stats.errors || 0
}));
}
publishFinalStats(stats) {
// Final summary
}
cleanup() {
// Close connections, flush buffers
}
}
module.exports = {
Plugin: CustomMetricsPlugin
};config:
plugins:
custom-metrics:
someConfig: "value"Artillery instantiates the plugin class with (config, events). The events object is an EventEmitter. Subscribe to stats for periodic metrics and done for the final summary.
Combining Plugins
Plugins compose — you can run multiple simultaneously:
config:
plugins:
expect: {}
publish-metrics:
- type: datadog
apiKey: "{{ $processEnvironment.DATADOG_API_KEY }}"
prefix: "artillery."
tags:
- "env:staging"
- type: statsd
host: "metrics.internal"
port: 8125In this config, assertions via expect run in-process, and metrics go to both Datadog and StatsD simultaneously.
Installed Plugins vs. Package.json
For team projects, manage plugins in package.json:
{
"devDependencies": {
"artillery": "^2.0.0",
"artillery-plugin-expect": "^2.0.0",
"artillery-plugin-publish-metrics": "^2.0.0"
}
}npm install
npx artillery run test.yamlThis ensures everyone on the team and CI uses the same plugin versions. Global installs (npm install -g) lead to version drift and "works on my machine" debugging sessions.
Useful Processor Patterns
Rate limiting your own test — sometimes you want to slow down a specific step:
function rateLimitedRequest(context, events, done) {
setTimeout(() => done(), 100); // add 100ms delay
}Aborting a scenario on failure:
function checkCriticalResponse(requestParams, response, context, events, done) {
if (response.statusCode !== 200) {
// Signal Artillery to abandon this VU's scenario
context.vars.__abort = true;
}
return done();
}Logging slow requests:
function logSlowRequests(requestParams, response, context, events, done) {
const duration = response.timings.phases.total;
if (duration > 1000) {
console.warn(`Slow request: ${requestParams.method} ${requestParams.url} took ${duration}ms`);
events.emit('counter', 'slow_requests', 1);
}
return done();
}These patterns give you observability beyond what Artillery's built-in reporting provides, and they integrate naturally with your existing monitoring infrastructure.