first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
**/.terraform/*
**/*.tfstate*
**/*.tfvars
privatekey.pem
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
FROM node:24.13.1 AS base
# Install required dependencies
RUN apt-get update && apt-get install -y gnupg2 curl
# Add k6 GPG key and repository
RUN mkdir -p /etc/apt/keyrings && \
curl -sS https://dl.k6.io/key.gpg | gpg --dearmor --yes -o /etc/apt/keyrings/k6.gpg && \
chmod a+x /etc/apt/keyrings/k6.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/k6.gpg] https://dl.k6.io/deb stable main" | tee /etc/apt/sources.list.d/k6.list
# Update and install k6
RUN apt-get update && \
apt-get install -y k6 tini && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack@0.33 && corepack enable
#
# Builder
FROM base AS builder
WORKDIR /app
COPY --chown=node:node ./pnpm-lock.yaml /app/pnpm-lock.yaml
COPY --chown=node:node ./pnpm-workspace.yaml /app/pnpm-workspace.yaml
COPY --chown=node:node ./package.json /app/package.json
COPY --chown=node:node ./packages/@n8n/benchmark/package.json /app/packages/@n8n/benchmark/package.json
COPY --chown=node:node ./patches /app/patches
COPY --chown=node:node ./scripts /app/scripts
ENV DOCKER_BUILD=true
RUN pnpm install --frozen-lockfile
# TS config files
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.common.json /app/packages/@n8n/typescript-config/tsconfig.common.json
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.build.json /app/packages/@n8n/typescript-config/tsconfig.build.json
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.backend.json /app/packages/@n8n/typescript-config/tsconfig.backend.json
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json
# Source files
COPY --chown=node:node ./packages/@n8n/benchmark/src /app/packages/@n8n/benchmark/src
COPY --chown=node:node ./packages/@n8n/benchmark/bin /app/packages/@n8n/benchmark/bin
COPY --chown=node:node ./packages/@n8n/benchmark/scenarios /app/packages/@n8n/benchmark/scenarios
WORKDIR /app/packages/@n8n/benchmark
RUN pnpm build
#
# Runner
FROM base AS runner
COPY --from=builder /app /app
WORKDIR /app/packages/@n8n/benchmark
USER node
ENTRYPOINT [ "/app/packages/@n8n/benchmark/bin/n8n-benchmark" ]
+122
View File
@@ -0,0 +1,122 @@
# n8n benchmarking tool
Tool for executing benchmarks against an n8n instance.
## Directory structure
```text
packages/@n8n/benchmark
├── scenarios Benchmark scenarios
├── src Source code for the n8n-benchmark cli
├── Dockerfile Dockerfile for the n8n-benchmark cli
├── scripts Orchestration scripts
```
## Benchmarking an existing n8n instance
The easiest way to run the existing benchmark scenarios is to use the benchmark docker image:
```sh
docker pull ghcr.io/n8n-io/n8n-benchmark:latest
# Print the help to list all available flags
docker run ghcr.io/n8n-io/n8n-benchmark:latest run --help
# Run all available benchmark scenarios for 1 minute with 5 concurrent requests
docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
--n8nBaseUrl=https://instance.url \
--n8nUserEmail=InstanceOwner@email.com \
--n8nUserPassword=InstanceOwnerPassword \
--vus=5 \
--duration=1m \
--scenarioFilter=single-webhook
```
### Using custom scenarios with the Docker image
It is also possible to create your own [benchmark scenarios](#benchmark-scenarios) and load them using the `--testScenariosPath` flag:
```sh
# Assuming your scenarios are located in `./scenarios`, mount them into `/scenarios` in the container
docker run -v ./scenarios:/scenarios ghcr.io/n8n-io/n8n-benchmark:latest run \
--n8nBaseUrl=https://instance.url \
--n8nUserEmail=InstanceOwner@email.com \
--n8nUserPassword=InstanceOwnerPassword \
--vus=5 \
--duration=1m \
--testScenariosPath=/scenarios
```
## Running the entire benchmark suite
The benchmark suite consists of [benchmark scenarios](#benchmark-scenarios) and different [n8n setups](#n8n-setups).
### locally
```sh
pnpm benchmark-locally
```
You can filter to a specific scenario and setup:
```sh
# Run only the http-node scenario with the sqlite setup
pnpm benchmark-locally --runDir /tmp/n8n-data --scenarioFilter http-node sqlite
```
### In the cloud
```sh
pnpm benchmark-in-cloud
```
## Running the `n8n-benchmark` cli
The `n8n-benchmark` cli is a node.js program that runs one or more scenarios against a single n8n instance.
### Locally with Docker
Build the Docker image:
```sh
# Must be run in the repository root
# k6 doesn't have an arm64 build available for linux, we need to build against amd64
docker build --platform linux/amd64 -t n8n-benchmark -f packages/@n8n/benchmark/Dockerfile .
```
Run the image
```sh
docker run \
-e N8N_USER_EMAIL=user@n8n.io \
-e N8N_USER_PASSWORD=password \
# For macos, n8n running outside docker
-e N8N_BASE_URL=http://host.docker.internal:5678 \
n8n-benchmark
```
### Locally without Docker
Requirements:
- [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)
- Node.js v20 or higher
```sh
pnpm build
# Run tests against http://localhost:5678 with specified email and password
N8N_USER_EMAIL=user@n8n.io N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
```
## Benchmark scenarios
A benchmark scenario defines one or multiple steps to execute and measure. It consists of:
- Manifest file which describes and configures the scenario
- Any test data that is imported before the scenario is run
- A [`k6`](https://grafana.com/docs/k6/latest/using-k6/http-requests/) script which executes the steps and receives `API_BASE_URL` environment variable in runtime.
Available scenarios are located in [`./scenarios`](./scenarios/).
## n8n setups
A n8n setup defines a single n8n runtime configuration using Docker compose. Different n8n setups are located in [`./scripts/n8nSetups`](./scripts/n8nSetups).
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
// Check if version should be displayed
const versionFlags = ['-v', '-V', '--version'];
if (versionFlags.includes(process.argv.slice(-1)[0])) {
console.log(require('../package').version);
process.exit(0);
}
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../biome.jsonc"],
"files": {
"ignore": ["scripts/mock-api/**"]
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(
nodeConfig,
globalIgnores(['scenarios/**', 'scripts/**']),
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'n8n-local-rules/no-plain-errors': 'off',
complexity: 'error',
'@typescript-eslint/naming-convention': 'warn',
'no-empty': 'warn',
},
},
{
files: ['./src/commands/*.ts'],
rules: {
'import-x/no-default-export': 'off',
},
},
);
+60
View File
@@ -0,0 +1,60 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azurerm" {
version = "3.115.0"
constraints = "~> 3.115.0"
hashes = [
"h1:O7C3Xb+MSOc9C/eAJ5C/CiJ4vuvUsYxxIzr9ZurmHNI=",
"zh:0ea93abd53cb872691bad6d5625bda88b5d9619ea813c208b36e0ee236308589",
"zh:26703cb9c2c38bc43e97bc83af03559d065750856ea85834b71fbcb2ef9d935c",
"zh:316255a3391c49fe9bd7c5b6aa53b56dd490e1083d19b722e7b8f956a2dfe004",
"zh:431637ae90c592126fb1ec813fee6390604275438a0d5e15904c65b0a6a0f826",
"zh:4cee0fa2e84f89853723c0bc72b7debf8ea2ffffc7ae34ff28d8a69269d3a879",
"zh:64a3a3c78ea877515365ed336bd0f3abbe71db7c99b3d2837915fbca168d429c",
"zh:7380d7b503b5a87fd71a31360c3eeab504f78e4f314824e3ceda724d9dc74cf0",
"zh:974213e05708037a6d2d8c58cc84981819138f44fe40e344034eb80e16ca6012",
"zh:9a91614de0476074e9c62bbf08d3bb9c64adbd1d3a4a2b5a3e8e41d9d6d5672f",
"zh:a438471c85b8788ab21bdef4cd5ca391a46cbae33bd0262668a80f5e6c4610e1",
"zh:bf823f2c941b336a1208f015466212b1a8fdf6da28abacf59bea708377709d9e",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.6.2"
hashes = [
"h1:VavG5unYCa3SYISMKF9pzc3718M0bhPlcbUZZGl7wuo=",
"zh:0ef01a4f81147b32c1bea3429974d4d104bbc4be2ba3cfa667031a8183ef88ec",
"zh:1bcd2d8161e89e39886119965ef0f37fcce2da9c1aca34263dd3002ba05fcb53",
"zh:37c75d15e9514556a5f4ed02e1548aaa95c0ecd6ff9af1119ac905144c70c114",
"zh:4210550a767226976bc7e57d988b9ce48f4411fa8a60cd74a6b246baf7589dad",
"zh:562007382520cd4baa7320f35e1370ffe84e46ed4e2071fdc7e4b1a9b1f8ae9b",
"zh:5efb9da90f665e43f22c2e13e0ce48e86cae2d960aaf1abf721b497f32025916",
"zh:6f71257a6b1218d02a573fc9bff0657410404fb2ef23bc66ae8cd968f98d5ff6",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:9647e18f221380a85f2f0ab387c68fdafd58af6193a932417299cdcae4710150",
"zh:bb6297ce412c3c2fa9fec726114e5e0508dd2638cad6a0cb433194930c97a544",
"zh:f83e925ed73ff8a5ef6e3608ad9225baa5376446349572c2449c0c0b3cf184b7",
"zh:fbef0781cb64de76b1df1ca11078aecba7800d82fd4a956302734999cfd9a4af",
]
}
provider "registry.terraform.io/hashicorp/tls" {
version = "4.0.5"
hashes = [
"h1:zeG5RmggBZW/8JWIVrdaeSJa0OG62uFX5HY1eE8SjzY=",
"zh:01cfb11cb74654c003f6d4e32bbef8f5969ee2856394a96d127da4949c65153e",
"zh:0472ea1574026aa1e8ca82bb6df2c40cd0478e9336b7a8a64e652119a2fa4f32",
"zh:1a8ddba2b1550c5d02003ea5d6cdda2eef6870ece86c5619f33edd699c9dc14b",
"zh:1e3bb505c000adb12cdf60af5b08f0ed68bc3955b0d4d4a126db5ca4d429eb4a",
"zh:6636401b2463c25e03e68a6b786acf91a311c78444b1dc4f97c539f9f78de22a",
"zh:76858f9d8b460e7b2a338c477671d07286b0d287fd2d2e3214030ae8f61dd56e",
"zh:a13b69fb43cb8746793b3069c4d897bb18f454290b496f19d03c3387d1c9a2dc",
"zh:a90ca81bb9bb509063b736842250ecff0f886a91baae8de65c8430168001dad9",
"zh:c4de401395936e41234f1956ebadbd2ed9f414e6908f27d578614aaa529870d4",
"zh:c657e121af8fde19964482997f0de2d5173217274f6997e16389e7707ed8ece8",
"zh:d68b07a67fbd604c38ec9733069fbf23441436fecf554de6c75c032f82e1ef19",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
@@ -0,0 +1,54 @@
data "azurerm_resource_group" "main" {
name = var.resource_group_name
}
# Random prefix for the resources
resource "random_string" "prefix" {
length = 8
special = false
}
# SSH key pair
resource "tls_private_key" "ssh_key" {
algorithm = "RSA"
rsa_bits = 4096
}
# Dedicated Host Group & Hosts
resource "azurerm_dedicated_host_group" "main" {
name = "${random_string.prefix.result}-hostgroup"
location = var.location
resource_group_name = data.azurerm_resource_group.main.name
platform_fault_domain_count = 1
automatic_placement_enabled = false
zone = 1
tags = local.common_tags
}
resource "azurerm_dedicated_host" "hosts" {
name = "${random_string.prefix.result}-host"
location = var.location
dedicated_host_group_id = azurerm_dedicated_host_group.main.id
sku_name = var.host_size_family
platform_fault_domain = 0
tags = local.common_tags
}
# VM
module "test_vm" {
source = "./modules/benchmark-vm"
location = var.location
resource_group_name = data.azurerm_resource_group.main.name
prefix = random_string.prefix.result
dedicated_host_id = azurerm_dedicated_host.hosts.id
ssh_public_key = tls_private_key.ssh_key.public_key_openssh
vm_size = var.vm_size
tags = local.common_tags
}
@@ -0,0 +1,11 @@
output "vm_name" {
value = azurerm_linux_virtual_machine.main.name
}
output "ip" {
value = azurerm_public_ip.main.ip_address
}
output "ssh_username" {
value = azurerm_linux_virtual_machine.main.admin_username
}
@@ -0,0 +1,29 @@
variable "location" {
description = "Region to deploy resources"
default = "East US"
}
variable "resource_group_name" {
description = "Name of the resource group"
}
variable "prefix" {
description = "Prefix to append to resources"
}
variable "dedicated_host_id" {
description = "Dedicated Host ID"
}
variable "ssh_public_key" {
description = "SSH Public Key"
}
variable "vm_size" {
description = "VM Size"
}
variable "tags" {
description = "Tags to apply to all resources created by this module"
type = map(string)
}
@@ -0,0 +1,126 @@
# Network
resource "azurerm_virtual_network" "main" {
name = "${var.prefix}-vnet"
location = var.location
resource_group_name = var.resource_group_name
address_space = ["10.0.0.0/16"]
tags = var.tags
}
resource "azurerm_subnet" "main" {
name = "${var.prefix}-subnet"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.0.0/24"]
}
resource "azurerm_network_security_group" "ssh" {
name = "${var.prefix}-nsg"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "AllowSSH"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = var.tags
}
resource "azurerm_public_ip" "main" {
name = "${var.prefix}-pip"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_interface" "main" {
name = "${var.prefix}-nic"
location = var.location
resource_group_name = var.resource_group_name
ip_configuration {
name = "${var.prefix}-ipconfig"
subnet_id = azurerm_subnet.main.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.main.id
}
tags = var.tags
}
resource "azurerm_network_interface_security_group_association" "ssh" {
network_interface_id = azurerm_network_interface.main.id
network_security_group_id = azurerm_network_security_group.ssh.id
}
# Disk
resource "azurerm_managed_disk" "data" {
name = "${var.prefix}-disk"
location = var.location
resource_group_name = var.resource_group_name
storage_account_type = "PremiumV2_LRS"
create_option = "Empty"
disk_size_gb = "32"
zone = 1
tags = var.tags
}
resource "azurerm_virtual_machine_data_disk_attachment" "data" {
managed_disk_id = azurerm_managed_disk.data.id
virtual_machine_id = azurerm_linux_virtual_machine.main.id
lun = "1"
caching = "None"
}
# VM
resource "azurerm_linux_virtual_machine" "main" {
name = "${var.prefix}-vm"
location = var.location
resource_group_name = var.resource_group_name
network_interface_ids = [azurerm_network_interface.main.id]
dedicated_host_id = var.dedicated_host_id
zone = 1
size = var.vm_size
admin_username = "benchmark"
admin_ssh_key {
username = "benchmark"
public_key = var.ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
identity {
type = "SystemAssigned"
}
tags = var.tags
}
+16
View File
@@ -0,0 +1,16 @@
output "vm_name" {
value = module.test_vm.vm_name
}
output "ip" {
value = module.test_vm.ip
}
output "ssh_username" {
value = module.test_vm.ssh_username
}
output "ssh_private_key" {
value = tls_private_key.ssh_key.private_key_pem
sensitive = true
}
@@ -0,0 +1,23 @@
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.115.0"
}
random = {
source = "hashicorp/random"
}
}
required_version = "~> 1.8.5"
}
provider "azurerm" {
features {}
skip_provider_registration = true
}
provider "random" {}
+34
View File
@@ -0,0 +1,34 @@
variable "location" {
description = "Region to deploy resources"
default = "East US"
}
variable "resource_group_name" {
description = "Name of the resource group"
default = "n8n-benchmarking"
}
variable "host_size_family" {
description = "Size Family for the Host Group"
default = "DCSv2-Type1"
}
variable "vm_size" {
description = "VM Size"
# 8 vCPUs, 32 GiB memory
default = "Standard_DC8_v2"
}
variable "number_of_vms" {
description = "Number of VMs to create"
default = 1
}
locals {
common_tags = {
Id = "N8nBenchmark"
Terraform = "true"
Owner = "Catalysts"
CreatedAt = timestamp()
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@n8n/n8n-benchmark",
"version": "2.4.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"start": "./bin/n8n-benchmark",
"test": "echo \"WARNING: no test specified\" && exit 0",
"typecheck": "tsc --noEmit",
"benchmark": "zx scripts/run.mjs",
"benchmark-in-cloud": "pnpm benchmark --env cloud",
"benchmark-locally": "pnpm benchmark --env local",
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
"destroy-cloud-env": "zx scripts/destroy-cloud-env.mjs",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
},
"engines": {
"node": ">=22.16"
},
"keywords": [
"automate",
"automation",
"IaaS",
"iPaaS",
"n8n",
"workflow",
"benchmark",
"performance"
],
"dependencies": {
"@oclif/core": "4.0.7",
"axios": "catalog:",
"dotenv": "17.2.3",
"nanoid": "catalog:",
"zx": "^8.8.5"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@types/convict": "^6.1.1",
"@types/k6": "^0.52.0"
},
"bin": {
"n8n-benchmark": "./bin/n8n-benchmark"
},
"oclif": {
"bin": "n8n-benchmark",
"commands": "./dist/commands",
"topicSeparator": " "
}
}
@@ -0,0 +1,67 @@
{
"createdAt": "2024-09-03T11:51:56.540Z",
"updatedAt": "2024-09-03T12:22:21.000Z",
"name": "Binary Data",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "binary-files-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"id": "bfe19f12-3655-440f-be5c-8d71665c6353",
"name": "Webhook",
"webhookId": "109d7b13-93ad-42b0-a9ce-ca49e1817b35"
},
{
"parameters": { "respondWith": "binary", "options": {} },
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [740, 0],
"id": "cd957c9b-6b7a-4423-aac3-6df4d8bb571e",
"name": "Respond to Webhook"
},
{
"parameters": {
"operation": "write",
"fileName": "=file-{{ Date.now() }}-{{ Math.random() }}.js",
"dataPropertyName": "file",
"options": {}
},
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [260, 0],
"id": "f2ce4709-7697-4bc6-8eca-6c222485297a",
"name": "Write File to Disk"
},
{
"parameters": { "fileSelector": "={{ $json.fileName }}", "options": {} },
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [500, 0],
"id": "198e8a6c-81a3-4b34-b099-501961a02006",
"name": "Read File from Disk"
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Write File to Disk", "type": "main", "index": 0 }]] },
"Write File to Disk": {
"main": [[{ "node": "Read File from Disk", "type": "main", "index": 0 }]]
},
"Read File from Disk": {
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": null,
"pinData": {},
"versionId": "8dd197c0-d1ea-43c3-9f88-9d11e7b081a0",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "BinaryData",
"description": "Send a binary file to a webhook, write it to FS, read it from FS and receive it back",
"scenarioData": { "workflowFiles": ["binary-data.json"] },
"scriptPath": "binary-data.script.js"
}
@@ -0,0 +1,29 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
// This creates a 2MB file (16 * 128 * 1024 = 2 * 1024 * 1024 = 2MB)
const file = Array.from({ length: 128 * 1024 }, () => Math.random().toString().slice(2)).join('');
const filename = 'test.bin';
export default function () {
const data = {
filename,
file: http.file(file, filename, 'application/javascript'),
};
const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has correct content type': (r) =>
r.headers['Content-Type'] === 'application/javascript; charset=utf-8',
});
}
@@ -0,0 +1,8 @@
{
"type": "httpBearerAuth",
"name": "Dummy HTTP credential",
"data": {
"token": "dummy token"
},
"id": "0fqzOReozl2aQvtl"
}
@@ -0,0 +1,241 @@
{
"name": "Credential HTTP Request",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "benchmark-credential-http-node",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-64, 32],
"id": "7dd66dcc-03c7-4898-ab3d-d2765730e3f3",
"name": "Webhook",
"webhookId": "92b141cd-6e59-4425-9c0a-e2ee0f4faad2"
},
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1072, 32],
"id": "e074e6a7-2417-4fde-8b6b-bc68069e833b",
"name": "Respond to Webhook"
},
{
"parameters": {
"url": "http://mockapi:8080/users/clair.bahringer/received_events/public",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [304, -176],
"id": "28ddbbf9-56a5-431e-afe9-3c44b21aa676",
"name": "Mock public received events",
"credentials": {
"httpBearerAuth": {
"id": "0fqzOReozl2aQvtl",
"name": "Dummy HTTP credential"
}
}
},
{
"parameters": {
"url": "http://mockapi:8080/repos/udke6pujoywnagxkcvab2riw23khzn2tibo2vincws32qexb50ey7h97d42vnzyol0rxypgsg4pomsf7sgnmdaihstljw8edcijrwmy7mfi76yif19c4/47i31dh737el215j62ts2f2782nw3ss26rul3s8jw13u3vu0xm349a5hyay5asmwnlnf7nx8p9h4g62so6s1cis7xv9puj5j98t4m980sbe2455fn1obccjl/events",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [304, 32],
"id": "3ce8827c-6226-467e-a4da-9891c1acd863",
"name": "Mock repository events",
"credentials": {
"httpBearerAuth": {
"id": "0fqzOReozl2aQvtl",
"name": "Dummy HTTP credential"
}
}
},
{
"parameters": {
"url": "http://mockapi:8080/orgs/g02pp066qoyithcjevhd6m1wfii3c4x51k39n9apybljhx69/events",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [304, 224],
"id": "a8e416ab-50cc-4e50-8d9a-9fcf5d4bbdc8",
"name": "Mock organization events",
"credentials": {
"httpBearerAuth": {
"id": "0fqzOReozl2aQvtl",
"name": "Dummy HTTP credential"
}
}
},
{
"parameters": {
"numberInputs": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [608, 32],
"id": "542a27d4-3a03-4c22-a79a-7266050c519e",
"name": "Merge"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "89608adb-f487-416f-a7d8-3ebb1f7b50e5",
"name": "statusCode",
"value": "={{ $json.statusCode }}",
"type": "number"
}
]
},
"options": {}
},
"id": "35d4bfbb-d4be-49f4-a5dd-bd5c67a48200",
"name": "Select statusCode",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [832, 32]
}
],
"pinData": {
"Webhook": [
{
"json": {
"headers": {
"host": "localhost:5678",
"user-agent": "curl/8.6.0",
"accept": "*/*"
},
"params": {},
"query": {},
"body": {},
"webhookUrl": "http://localhost:5678/webhook-test/benchmark-credential-http-node",
"executionMode": "test"
}
}
]
},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Mock public received events",
"type": "main",
"index": 0
},
{
"node": "Mock repository events",
"type": "main",
"index": 0
},
{
"node": "Mock organization events",
"type": "main",
"index": 0
}
]
]
},
"Mock public received events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Mock repository events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Mock organization events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 2
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Select statusCode",
"type": "main",
"index": 0
}
]
]
},
"Select statusCode": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "96bfc5ef-9421-47f5-9fdd-432dbd4bc4ca",
"meta": {
"instanceId": "4141065f11bd5ed73fef4f9b1d91842ded0ec4058e6640a98aa14384e269204b"
},
"id": "6V8rTiIDqZOniAs1",
"tags": []
}
@@ -0,0 +1,10 @@
{
"$schema": "../scenario.schema.json",
"name": "CredentialHttpNode",
"description": "Webhook -> 3x HTTP request to a mock API -> Merge -> Respond to Webhook. Requires a mock API running at http://mockapi:8080",
"scenarioData": {
"workflowFiles": ["credential-http-node.json"],
"credentialFiles": ["credential-bearer.json"]
},
"scriptPath": "credential-http-node.script.js"
}
@@ -0,0 +1,30 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/benchmark-credential-http-node`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'http requests were OK': (r) => {
if (r.status !== 200) return false;
try {
// Response body is an array of the request status codes made with HttpNodes
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.every((request) => request.statusCode === 200) : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}
@@ -0,0 +1,168 @@
{
"createdAt": "2025-09-24T12:19:51.268Z",
"updatedAt": "2025-09-24T12:20:45.000Z",
"name": "Data table node",
"active": true,
"nodes": [
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [688, 0],
"id": "a7eeb256-a47e-4fe9-a0e1-b33015912b15",
"name": "Respond to Webhook"
},
{
"parameters": {
"httpMethod": "POST",
"path": "data-table-node-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [-288, 0],
"id": "b54681a1-6cfc-4970-ad33-25cb8fec6936",
"name": "Webhook",
"webhookId": "4748ca75-3b3c-4e4a-8913-3590fdc1867d"
},
{
"parameters": {
"fieldToSplitOut": "body.items",
"options": {}
},
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [96, 0],
"id": "019d451f-604f-4ed6-8f40-1beb86445061",
"name": "Split Out"
},
{
"parameters": {
"dataTableId": {
"__rl": true,
"value": "={{ $('Webhook').item.json.body.dataTableId }}",
"mode": "list"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"isActive": "={{ $json.isActive }}",
"firstName": "={{ $json.firstName }}",
"birthDate": "={{ $json.birthDate }}",
"age": "={{ $json.age }}"
},
"matchingColumns": [],
"schema": [
{
"id": "firstName",
"displayName": "firstName",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "age",
"displayName": "age",
"required": false,
"defaultMatch": false,
"display": true,
"type": "number",
"readOnly": false,
"removed": false
},
{
"id": "birthDate",
"displayName": "birthDate",
"required": false,
"defaultMatch": false,
"display": true,
"type": "dateTime",
"readOnly": false,
"removed": false
},
{
"id": "isActive",
"displayName": "isActive",
"required": false,
"defaultMatch": false,
"display": true,
"type": "boolean",
"readOnly": false,
"removed": false
},
{
"id": "empty",
"displayName": "empty",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [448, 0],
"id": "05a47403-6c7d-4aad-b0eb-ca726e7594c9",
"name": "Insert row"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Split Out",
"type": "main",
"index": 0
}
]
]
},
"Insert row": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Split Out": {
"main": [
[
{
"node": "Insert row",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"versionId": "cdd59544-4b5d-42c0-a07f-8ab48a9d849c",
"triggerCount": 1,
"tags": [],
"meta": {
"responseMode": "lastNode",
"options": {}
}
}
@@ -0,0 +1,10 @@
{
"$schema": "../scenario.schema.json",
"name": "DataTableNode",
"description": "A Data table node that first inserts 100 items and then reads them from the table. The data is returned with RespondToWebhook Node.",
"scenarioData": {
"workflowFiles": ["data-table-node.json"],
"dataTableFile": "data-table.json"
},
"scriptPath": "data-table-node.script.js"
}
@@ -0,0 +1,38 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
const dataTableId = __ENV.DATA_TABLE_ID;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/data-table-node-benchmark`, {
dataTableId: dataTableId,
items: Array.from({ length: 100 }, (_, i) => ({
firstName: `Item ${i + 1}`,
age: Math.floor(Math.random() * 100),
birthDate: new Date(),
isActive: i % 2 === 0,
})),
});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has items in response': (r) => {
if (r.status !== 200) return false;
try {
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.length === 100 : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}
@@ -0,0 +1,25 @@
{
"name": "data-table-node-benchmark",
"columns": [
{
"name": "firstName",
"type": "string"
},
{
"name": "age",
"type": "number"
},
{
"name": "birthDate",
"type": "date"
},
{
"name": "isActive",
"type": "boolean"
},
{
"name": "empty",
"type": "string"
}
]
}
@@ -0,0 +1,213 @@
{
"createdAt": "2024-09-04T07:18:29.011Z",
"updatedAt": "2024-09-04T07:27:58.000Z",
"id": "rUXzWNGsUDUmgaFS",
"name": "HTTP Request",
"active": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "benchmark-http-node",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-60, 20],
"id": "f11378b4-5f28-4a6c-9332-5878342cd3cf",
"name": "Webhook",
"webhookId": "c40014cc-4d64-4fcf-8c13-9e94b6792756"
},
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1060, 20],
"id": "f42552c7-9c6e-4616-b9d5-ac79445ef4ed",
"name": "Respond to Webhook"
},
{
"parameters": {
"url": "http://mockapi:8080/users/clair.bahringer/received_events/public",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, -180],
"id": "20de816e-0fbe-4e28-bc53-f508a2dda117",
"name": "Mock public received events"
},
{
"parameters": {
"url": "http://mockapi:8080/repos/udke6pujoywnagxkcvab2riw23khzn2tibo2vincws32qexb50ey7h97d42vnzyol0rxypgsg4pomsf7sgnmdaihstljw8edcijrwmy7mfi76yif19c4/47i31dh737el215j62ts2f2782nw3ss26rul3s8jw13u3vu0xm349a5hyay5asmwnlnf7nx8p9h4g62so6s1cis7xv9puj5j98t4m980sbe2455fn1obccjl/events",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, 20],
"id": "083e02b3-a257-49a8-8f7d-42222cb9194c",
"name": "Mock repository events"
},
{
"parameters": {
"url": "http://mockapi:8080/orgs/g02pp066qoyithcjevhd6m1wfii3c4x51k39n9apybljhx69/events",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, 220],
"id": "f4c3b5d2-0257-4883-a585-ade4c3a1082c",
"name": "Mock organization events"
},
{
"parameters": {
"numberInputs": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [600, 20],
"id": "273985b7-b0ae-4cde-bbe9-7b3e4b29fe61",
"name": "Merge"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "89608adb-f487-416f-a7d8-3ebb1f7b50e5",
"name": "statusCode",
"value": "={{ $json.statusCode }}",
"type": "number"
}
]
},
"options": {}
},
"id": "231275a7-44e7-47bb-8ccf-fe62dc48356b",
"name": "Select statusCode",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [820, 20]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Mock public received events",
"type": "main",
"index": 0
},
{
"node": "Mock repository events",
"type": "main",
"index": 0
},
{
"node": "Mock organization events",
"type": "main",
"index": 0
}
]
]
},
"Mock public received events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Mock repository events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Mock organization events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 2
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Select statusCode",
"type": "main",
"index": 0
}
]
]
},
"Select statusCode": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": null,
"pinData": {
"Webhook": [
{
"json": {
"headers": { "host": "localhost:5678", "user-agent": "curl/8.6.0", "accept": "*/*" },
"params": {},
"query": {},
"body": {},
"webhookUrl": "http://localhost:5678/webhook-test/benchmark-http-node",
"executionMode": "test"
}
}
]
},
"versionId": "9fa91e54-e73a-4a34-b781-d64f2b02f333",
"triggerCount": 0,
"tags": []
}
@@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "HttpNode",
"description": "Webhook -> 3x HTTP request to a mock API -> Merge -> Respond to Webhook. Requires a mock API running at http://mockapi:8080",
"scenarioData": { "workflowFiles": ["http-node.json"] },
"scriptPath": "http-node.script.js"
}
@@ -0,0 +1,30 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'http requests were OK': (r) => {
if (r.status !== 200) return false;
try {
// Response body is an array of the request status codes made with HttpNodes
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.every((request) => request.statusCode === 200) : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}
@@ -0,0 +1,96 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "JS Code Node",
"active": true,
"nodes": [
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1280, 460],
"id": "0067e317-09b8-478a-8c50-e19b4c9e294c",
"name": "Respond to Webhook"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add new field\n$input.item.json.age = 10 + Math.floor(Math.random() * 30);\n// Mutate existing field\n$input.item.json.password = $input.item.json.password.split('').map(() => '*').join(\"\")\n// Remove field\ndelete $input.item.json.lastname\n// New object field\nconst emailParts = $input.item.json.email.split(\"@\")\n$input.item.json.emailData = {\n user: emailParts[0],\n domain: emailParts[1]\n}\n\nreturn $input.item;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1040, 460],
"id": "56d751c0-0d30-43c3-89fa-bebf3a9d436f",
"name": "OnceForEachItemJSCode"
},
{
"parameters": {
"httpMethod": "POST",
"path": "code-node-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [580, 460],
"id": "417d749d-156c-4ffe-86ea-336f702dc5da",
"name": "Webhook",
"webhookId": "34ca1895-ccf4-4a4a-8bb8-a042f5edb567"
},
{
"parameters": {
"jsCode": "const digits = '0123456789';\nconst uppercaseLetters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';\nconst lowercaseLetters = uppercaseLetters.toLowerCase();\nconst alphabet = [digits, uppercaseLetters, lowercaseLetters].join('').split('')\n\nconst randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;\nconst randomItem = (arr) => arr.at(randomInt(0, arr.length - 1))\nconst randomString = (len) => Array.from({ length: len }).map(() => randomItem(alphabet)).join('')\n\nconst randomUid = () => [8,4,4,4,8].map(len => randomString(len)).join(\"-\")\nconst randomEmail = () => `${randomString(8)}@${randomString(10)}.com`\n\nconst randomPerson = () => ({\n uid: randomUid(),\n email: randomEmail(),\n firstname: randomString(5),\n lastname: randomString(12),\n password: randomString(10)\n})\n\nreturn Array.from({ length: 100 }).map(() => ({\n json: randomPerson()\n}))"
},
"id": "c30db155-73ca-48b9-8860-c3fe7a0926fb",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [820, 460]
}
],
"connections": {
"OnceForEachItemJSCode": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "OnceForEachItemJSCode",
"type": "main",
"index": 0
}
]
]
}
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "CodeNodeJs",
"description": "A JS Code Node that first generates 100 items and then runs once for each item and adds, modifies and removes properties. The data is returned with RespondToWebhook Node.",
"scenarioData": { "workflowFiles": ["js-code-node.json"] },
"scriptPath": "js-code-node.script.js"
}
@@ -0,0 +1,29 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/code-node-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has items in response': (r) => {
if (r.status !== 200) return false;
try {
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.length === 100 : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}
@@ -0,0 +1,20 @@
{
"$schema": "../scenario.schema.json",
"name": "MultipleWebhooks",
"description": "10 simple webhooks that respond with a 200 status code",
"scenarioData": {
"workflowFiles": [
"multiple-webhooks1.json",
"multiple-webhooks2.json",
"multiple-webhooks3.json",
"multiple-webhooks4.json",
"multiple-webhooks5.json",
"multiple-webhooks6.json",
"multiple-webhooks7.json",
"multiple-webhooks8.json",
"multiple-webhooks9.json",
"multiple-webhooks10.json"
]
},
"scriptPath": "multiple-webhooks.script.js"
}
@@ -0,0 +1,19 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const urls = Array(10)
.fill()
.map((_, i) => `${apiBaseUrl}/webhook/multiple-webhook${i + 1}`);
const res = http.batch(urls);
for (let i = 0; i < res.length; i++) {
// Check if the response status is 200
check(res[i], {
'is status 200': (r) => r.status === 200,
});
}
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 1",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook1", "options": {} },
"id": "b239bc6c-ea40-438d-bb14-5bce03599685",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "14226baf-ea56-43d7-bc0d-eea841746441"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "0322386c-ec84-4d38-b895-c206b574f31c",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 10",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook10", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 2",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook2", "options": {} },
"id": "c44ce610-086c-45a6-b347-c7b5df5365ed",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "edc1994b-7cde-4aed-b077-fbc7f21b0f48"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "b4579dbf-d9c5-4b57-8e1f-883d91e8726b",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 3",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook3", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 4",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook4", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 5",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook5", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 6",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook6", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 7",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook7", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 8",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook8", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Multiple Webhook 9",
"active": true,
"nodes": [
{
"parameters": { "path": "multiple-webhook9", "options": {} },
"id": "34ac4500-9a29-4f4f-a604-134aa2cb2889",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "47fe25ac-1376-4ee7-b9de-3fff1d49281c"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "106d4d2c-49c0-45b8-8421-99cc0c8b1589",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,98 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Python Code Node",
"active": true,
"nodes": [
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1280, 460],
"id": "0067e317-09b8-478a-8c50-e19b4c9e294c",
"name": "Respond to Webhook"
},
{
"parameters": {
"language": "pythonNative",
"mode": "runOnceForEachItem",
"pythonCode": "def pseudo_random(seed_str, max_val):\n return hash(seed_str) % max_val\n\n# Add new field\n_item['json']['age'] = 10 + pseudo_random(str(_item['json']['email']), 30)\n\n# Mutate existing field\n_item['json']['password'] = '*' * len(_item['json']['password'])\n\n# Remove field\nif 'lastname' in _item['json']:\n del _item['json']['lastname']\n\n# New object field\nemail_parts = _item['json']['email'].split('@')\n_item['json']['emailData'] = {\n 'user': email_parts[0],\n 'domain': email_parts[1]\n}\n\nreturn _item"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1040, 460],
"id": "56d751c0-0d30-43c3-89fa-bebf3a9d436f",
"name": "OnceForEachItemPythonCode"
},
{
"parameters": {
"httpMethod": "POST",
"path": "py-code-node-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [580, 460],
"id": "417d749d-156c-4ffe-86ea-336f702dc5da",
"name": "Webhook",
"webhookId": "34ca1895-ccf4-4a4a-8bb8-a042f5edb567"
},
{
"parameters": {
"language": "pythonNative",
"pythonCode": "def pseudo_random_string(length):\n characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\n result = ''\n seed = hash(str(length)) % 1000\n for i in range(length):\n index = (hash(str(seed + i)) % len(characters))\n result += characters[index]\n seed = (seed * 31 + i) % 1000\n return result\n\ndef random_uid():\n lengths = [8, 4, 4, 4, 8]\n parts = [pseudo_random_string(length) for length in lengths]\n return '-'.join(parts)\n\ndef random_email():\n return f\"{pseudo_random_string(8)}@{pseudo_random_string(10)}.com\"\n\ndef random_person():\n return {\n 'uid': random_uid(),\n 'email': random_email(),\n 'firstname': pseudo_random_string(5),\n 'lastname': pseudo_random_string(12),\n 'password': pseudo_random_string(10)\n }\n\nreturn [{'json': random_person()} for _ in range(100)]"
},
"id": "c30db155-73ca-48b9-8860-c3fe7a0926fb",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [820, 460]
}
],
"connections": {
"OnceForEachItemPythonCode": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "OnceForEachItemPythonCode",
"type": "main",
"index": 0
}
]
]
}
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "CodeNodePython",
"description": "A Python Code Node that first generates 100 items and then runs once for each item and adds, modifies and removes properties. The data is returned with RespondToWebhook Node.",
"scenarioData": { "workflowFiles": ["py-code-node.json"] },
"scriptPath": "py-code-node.script.js"
}
@@ -0,0 +1,29 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/py-code-node-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has items in response': (r) => {
if (r.status !== 200) return false;
try {
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.length === 100 : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}
@@ -0,0 +1,51 @@
{
"definitions": {
"ScenarioData": {
"type": "object",
"properties": {
"workflowFiles": {
"type": "array",
"items": {
"type": "string"
}
},
"credentialFiles": {
"type": "array",
"items": {
"type": "string"
}
},
"dataTableFile": {
"type": "string"
}
},
"required": [],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"$schema": {
"type": "string",
"description": "The JSON schema to validate this file"
},
"name": {
"type": "string",
"description": "The name of the scenario"
},
"description": {
"type": "string",
"description": "A longer description of the scenario"
},
"scriptPath": {
"type": "string",
"description": "Relative path to the k6 test script"
},
"scenarioData": {
"$ref": "#/definitions/ScenarioData",
"description": "Data to import before running the scenario"
}
},
"required": ["name", "description", "scriptPath", "scenarioData"],
"additionalProperties": false
}
@@ -0,0 +1,91 @@
{
"createdAt": "2024-09-03T11:30:26.333Z",
"updatedAt": "2024-09-03T11:42:52.000Z",
"name": "Set Node Expressions",
"active": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "set-expressions-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [40, 0],
"id": "5babc228-2b89-48cb-8337-28416e867874",
"name": "Webhook",
"webhookId": "f6f1750d-b734-496f-afe8-26e8e393ca87"
},
{
"parameters": { "respondWith": "allIncomingItems", "options": {} },
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [640, 0],
"id": "4146a3fb-403c-4cfc-9d38-8af4d16a8440",
"name": "Respond to Webhook"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "48c46098-f411-41f7-8f0a-1da372340a4e",
"name": "oneToOneCopy",
"value": "={{ $json.headers.host }}",
"type": "string"
},
{
"id": "5d90808b-1c1a-4065-ac51-6d61bd03e564",
"name": "={{ $json.headers['user-agent'].slice(0, 4) }}",
"value": "Set key with expression",
"type": "string"
},
{
"id": "8a74ac24-1f43-43ba-969d-87bfd2f401ce",
"name": "Multiple variables",
"value": "={{ $json.executionMode + ' ' + $json.webhookUrl }}",
"type": "string"
},
{
"id": "93eba201-79d9-4305-a246-f9c8ec50ebab",
"name": "Static value",
"value": 42,
"type": "number"
},
{
"id": "0470a712-c795-44ab-9dcc-05a3f67698bb",
"name": "Object",
"value": "={{ $json.headers }}",
"type": "object"
},
{
"id": "eb671167-da14-4b55-8eea-31ab7bedae10",
"name": "Array",
"value": "={{ Object.values($json.headers) }}",
"type": "array"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [360, 0],
"id": "0cb5e82d-f61e-4d91-8fa9-365e382a4d75",
"name": "Edit Fields"
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]] },
"Edit Fields": { "main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": null,
"pinData": {},
"versionId": "04fd543e-3923-4092-8c2b-2b4262ccbb38",
"triggerCount": 0,
"tags": []
}
@@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "SetNodeExpressions",
"description": "Expressions in a Set node",
"scenarioData": { "workflowFiles": ["set-node-expressions.json"] },
"scriptPath": "set-node-expressions.script.js"
}
@@ -0,0 +1,18 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
});
}
@@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Single Webhook",
"active": true,
"nodes": [
{
"parameters": { "path": "single-webhook", "options": {} },
"id": "7587ab0e-cc15-424f-83c0-c887a0eb97fb",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "fa563fc2-c73f-4631-99a1-39c16f1f858f"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
"triggerCount": 1,
"tags": []
}
@@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "SingleWebhook",
"description": "A single webhook trigger that responds with a 200 status code",
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
"scriptPath": "single-webhook.script.js"
}
@@ -0,0 +1,18 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
});
}
@@ -0,0 +1,63 @@
#!/bin/bash
#
# Script to initialize the benchmark environment on a VM
#
set -euo pipefail;
CURRENT_USER=$(whoami)
# Mount the data disk
# First wait for the disk to become available
WAIT_TIME=0
MAX_WAIT_TIME=60
while [ ! -e /dev/sdc ]; do
if [ $WAIT_TIME -ge $MAX_WAIT_TIME ]; then
echo "Error: /dev/sdc did not become available within $MAX_WAIT_TIME seconds."
exit 1
fi
echo "Waiting for /dev/sdc to be available... ($WAIT_TIME/$MAX_WAIT_TIME)"
sleep 1
WAIT_TIME=$((WAIT_TIME + 1))
done
# Then mount it
if [ -d "/n8n" ]; then
echo "Data disk already mounted. Clearing it..."
sudo rm -rf /n8n/*
sudo rm -rf /n8n/.[!.]*
else
sudo mkdir -p /n8n
sudo parted /dev/sdc --script mklabel gpt mkpart xfspart xfs 0% 100%
sudo mkfs.xfs /dev/sdc1
sudo partprobe /dev/sdc1
sudo mount /dev/sdc1 /n8n
sudo chown -R "$CURRENT_USER":"$CURRENT_USER" /n8n
fi
### Remove unneeded dependencies
# TTY
sudo systemctl disable getty@tty1.service
sudo systemctl disable serial-getty@ttyS0.service
# Snap
sudo systemctl disable snapd.service
# Unattended upgrades
sudo systemctl disable unattended-upgrades.service
# Cron
sudo systemctl disable cron.service
# Include nodejs v20 repository
curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
# Install docker, docker compose, nodejs
sudo DEBIAN_FRONTEND=noninteractive apt-get update -yq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yq docker.io docker-compose nodejs
# Add the current user to the docker group
sudo usermod -aG docker "$CURRENT_USER"
# Install zx
npm install zx
@@ -0,0 +1,45 @@
import { which } from 'zx';
export class DockerComposeClient {
/**
*
* @param {{ $: Shell; verbose?: boolean }} opts
*/
constructor({ $ }) {
this.$$ = $;
}
async $(...args) {
await this.resolveExecutableIfNeeded();
if (this.isCompose) {
return await this.$$`docker-compose ${args}`;
} else {
return await this.$$`docker compose ${args}`;
}
}
async resolveExecutableIfNeeded() {
if (this.isResolved) {
return;
}
// The VM deployment doesn't have `docker compose` available,
// so try to resolve the `docker-compose` first
const compose = await which('docker-compose', { nothrow: true });
if (compose) {
this.isResolved = true;
this.isCompose = true;
return;
}
const docker = await which('docker', { nothrow: true });
if (docker) {
this.isResolved = true;
this.isCompose = false;
return;
}
throw new Error('Could not resolve docker-compose or docker');
}
}
@@ -0,0 +1,37 @@
// @ts-check
import { $ } from 'zx';
export class SshClient {
/**
*
* @param {{ privateKeyPath: string; ip: string; username: string; verbose?: boolean }} param0
*/
constructor({ privateKeyPath, ip, username, verbose = false }) {
this.verbose = verbose;
this.privateKeyPath = privateKeyPath;
this.ip = ip;
this.username = username;
this.$$ = $({
verbose,
});
}
/**
* @param {string} command
* @param {{ verbose?: boolean }} [options]
*/
async ssh(command, options = {}) {
const $$ = options?.verbose ? $({ verbose: true }) : this.$$;
const target = `${this.username}@${this.ip}`;
await $$`ssh -i ${this.privateKeyPath} -o StrictHostKeyChecking=accept-new ${target} ${command}`;
}
async scp(source, destination) {
const target = `${this.username}@${this.ip}:${destination}`;
await this
.$$`scp -i ${this.privateKeyPath} -o StrictHostKeyChecking=accept-new ${source} ${target}`;
}
}
@@ -0,0 +1,71 @@
// @ts-check
import path from 'path';
import { $, fs } from 'zx';
const paths = {
infraCodeDir: path.resolve('infra'),
terraformStateFile: path.join(path.resolve('infra'), 'terraform.tfstate'),
};
export class TerraformClient {
constructor({ isVerbose = false }) {
this.isVerbose = isVerbose;
this.$$ = $({
cwd: paths.infraCodeDir,
verbose: isVerbose,
});
}
/**
* Provisions the environment
*/
async provisionEnvironment() {
console.log('Provisioning cloud environment...');
await this.$$`terraform init`;
await this.$$`terraform apply -input=false -auto-approve`;
}
/**
* @typedef {Object} BenchmarkEnv
* @property {string} vmName
* @property {string} ip
* @property {string} sshUsername
* @property {string} sshPrivateKeyPath
*
* @returns {Promise<BenchmarkEnv>}
*/
async getTerraformOutputs() {
const privateKeyName = await this.extractPrivateKey();
return {
ip: await this.getTerraformOutput('ip'),
sshUsername: await this.getTerraformOutput('ssh_username'),
sshPrivateKeyPath: path.join(paths.infraCodeDir, privateKeyName),
vmName: await this.getTerraformOutput('vm_name'),
};
}
hasTerraformState() {
return fs.existsSync(paths.terraformStateFile);
}
async destroyEnvironment() {
console.log('Destroying cloud environment...');
await this.$$`terraform destroy -input=false -auto-approve`;
}
async getTerraformOutput(key) {
const output = await this.$$`terraform output -raw ${key}`;
return output.stdout.trim();
}
async extractPrivateKey() {
await this.$$`terraform output -raw ssh_private_key > privatekey.pem`;
await this.$$`chmod 600 privatekey.pem`;
return 'privatekey.pem';
}
}
@@ -0,0 +1,86 @@
#!/usr/bin/env zx
/**
* Script that deletes all resources created by the benchmark environment.
*
* This scripts tries to delete resources created by Terraform. If Terraform
* state file is not found, it will try to delete resources using Azure CLI.
* The terraform state is not persisted, so we want to support both cases.
*/
// @ts-check
import { $, minimist } from 'zx';
import { TerraformClient } from './clients/terraform-client.mjs';
const RESOURCE_GROUP_NAME = 'n8n-benchmarking';
const args = minimist(process.argv.slice(3), {
boolean: ['debug'],
});
const isVerbose = !!args.debug;
async function main() {
const terraformClient = new TerraformClient({ isVerbose });
if (terraformClient.hasTerraformState()) {
await terraformClient.destroyEnvironment();
} else {
await destroyUsingAz();
}
}
async function destroyUsingAz() {
const resourcesResult =
await $`az resource list --resource-group ${RESOURCE_GROUP_NAME} --query "[?tags.Id == 'N8nBenchmark'].{id:id, createdAt:tags.CreatedAt}" -o json`;
const resources = JSON.parse(resourcesResult.stdout);
const resourcesToDelete = resources.map((resource) => resource.id);
if (resourcesToDelete.length === 0) {
console.log('No resources found in the resource group.');
return;
}
await deleteResources(resourcesToDelete);
}
async function deleteResources(resourceIds) {
// We don't know the order in which resource should be deleted.
// Here's a poor person's approach to try deletion until all complete
const MAX_ITERATIONS = 100;
let i = 0;
const toDelete = [...resourceIds];
console.log(`Deleting ${resourceIds.length} resources...`);
while (toDelete.length > 0) {
const resourceId = toDelete.shift();
const deleted = await deleteById(resourceId);
if (!deleted) {
toDelete.push(resourceId);
}
if (i++ > MAX_ITERATIONS) {
console.log(
`Max iterations reached. Exiting. Could not delete ${toDelete.length} resources.`,
);
process.exit(1);
}
}
}
async function deleteById(id) {
try {
await $`az resource delete --ids ${id}`;
return true;
} catch (error) {
return false;
}
}
main().catch((error) => {
console.error('An error occurred destroying cloud env:');
console.error(error);
process.exit(1);
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
postgres:
image: postgres:16.4
restart: always
user: root:root
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
# Task Runner config
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_METRICS=true
ports:
- 5678:5678
volumes:
- ${RUN_DIR}/n8n:/n8n
depends_on:
postgres:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
- N8N_RUNNERS_AUTH_TOKEN=test
- NO_COLOR=1
depends_on:
n8n:
condition: service_healthy
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
- COLLECT_APP_METRICS=true
@@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n', 'postgres'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}
@@ -0,0 +1,228 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6.2.14-alpine
restart: always
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 1s
timeout: 3s
postgres:
image: postgres:16.4
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 10
n8n_worker1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker1
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
# Task Runner config
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://localhost:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_worker1_runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker1:5679
- N8N_RUNNERS_AUTH_TOKEN=test
- NO_COLOR=1
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker2
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
# Task Runner config
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
depends_on:
# We let the worker 1 start first so it can run the DB migrations
n8n_worker1:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://localhost:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_worker2_runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker2:5679
- N8N_RUNNERS_AUTH_TOKEN=test
- NO_COLOR=1
depends_on:
n8n_worker2:
condition: service_healthy
n8n_main2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- N8N_PROXY_HOPS=1
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- N8N_MULTI_MAIN_SETUP_ENABLED=true
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
- N8N_METRICS=true
volumes:
- ${RUN_DIR}/n8n-main2:/n8n
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_main2:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_main1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- N8N_PROXY_HOPS=1
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- N8N_MULTI_MAIN_SETUP_ENABLED=true
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
- N8N_METRICS=true
volumes:
- ${RUN_DIR}/n8n-main1:/n8n
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_main1:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
# Load balancer that acts as an entry point for n8n
n8n:
image: nginx:1.27.2
ports:
- '5678:80'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
n8n_main1:
condition: service_healthy
n8n_main2:
condition: service_healthy
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
- n8n
environment:
- N8N_BASE_URL=http://n8n:80
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
- COLLECT_APP_METRICS=true
@@ -0,0 +1,24 @@
events {}
http {
client_max_body_size 50M;
access_log off;
error_log /dev/stderr warn;
upstream backend {
server n8n_main1:5678;
server n8n_main2:5678;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
@@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n-worker1', 'n8n-worker2', 'n8n-main1', 'n8n-main2', 'postgres'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}
@@ -0,0 +1,172 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6.2.14-alpine
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 1s
timeout: 3s
postgres:
image: postgres:16.4
user: root:root
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 10
n8n_worker1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker1
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Queue mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
# Task Runner config
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_worker1:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_worker1_runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker1:5679
- N8N_RUNNERS_AUTH_TOKEN=test
- NO_COLOR=1
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker2
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Queue mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
# Task Runner config
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
depends_on:
# We let the worker 1 start first so it can run the DB migrations
n8n_worker1:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_worker2:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_worker2_runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n_worker2:5679
- N8N_RUNNERS_AUTH_TOKEN=test
- NO_COLOR=1
depends_on:
n8n_worker2:
condition: service_healthy
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/main
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Queue mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
- N8N_METRICS=true
ports:
- 5678:5678
volumes:
- ${RUN_DIR}/n8n-main:/n8n
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
- COLLECT_APP_METRICS=true
@@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n-worker1', 'n8n-worker2', 'n8n-main', 'postgres'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}
@@ -0,0 +1,53 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- DB_SQLITE_POOL_SIZE=3
# Task Runner config
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_METRICS=true
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
depends_on:
mockapi:
condition: service_started
runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION:-latest}
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
- N8N_RUNNERS_AUTH_TOKEN=test
- NO_COLOR=1
depends_on:
n8n:
condition: service_healthy
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}
@@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}
@@ -0,0 +1,36 @@
#!/usr/bin/env zx
/**
* Provisions the cloud benchmark environment
*
* NOTE: Must be run in the root of the package.
*/
// @ts-check
import { which, minimist } from 'zx';
import { TerraformClient } from './clients/terraform-client.mjs';
const args = minimist(process.argv.slice(3), {
boolean: ['debug'],
});
const isVerbose = !!args.debug;
export async function provision() {
await ensureDependencies();
const terraformClient = new TerraformClient({
isVerbose,
});
await terraformClient.provisionEnvironment();
}
async function ensureDependencies() {
await which('terraform');
}
provision().catch((error) => {
console.error('An error occurred while provisioning cloud env:');
console.error(error);
process.exit(1);
});
@@ -0,0 +1,175 @@
#!/usr/bin/env zx
/**
* This script runs the benchmarks for the given n8n setup.
*/
// @ts-check
import path from 'path';
import { $, argv, fs } from 'zx';
import { DockerComposeClient } from './clients/docker-compose-client.mjs';
import { flagsObjectToCliArgs } from './utils/flags.mjs';
import { EOL } from 'os';
const paths = {
n8nSetupsDir: path.join(__dirname, 'n8n-setups'),
mockApiDataPath: path.join(__dirname, 'mock-api'),
};
const N8N_ENCRYPTION_KEY = 'very-secret-encryption-key';
/**
* Discovers runner services in the docker-compose setup, where the name matches exactly `runners` or ends with `_runners`
*/
async function discoverRunnerServices(dockerComposeClient) {
const result = await dockerComposeClient.$('config', '--services');
return result.stdout
.trim()
.split(EOL)
.filter((service) => service === 'runners' || service.endsWith('_runners'));
}
async function main() {
const [n8nSetupToUse] = argv._;
validateN8nSetup(n8nSetupToUse);
const composeFilePath = path.join(paths.n8nSetupsDir, n8nSetupToUse);
const setupScriptPath = path.join(paths.n8nSetupsDir, n8nSetupToUse, 'setup.mjs');
const n8nTag = argv.n8nDockerTag || process.env.N8N_DOCKER_TAG || 'latest';
const benchmarkTag = argv.benchmarkDockerTag || process.env.BENCHMARK_DOCKER_TAG || 'latest';
const k6ApiToken = argv.k6ApiToken || process.env.K6_API_TOKEN || undefined;
const resultWebhookUrl =
argv.resultWebhookUrl || process.env.BENCHMARK_RESULT_WEBHOOK_URL || undefined;
const resultWebhookAuthHeader =
argv.resultWebhookAuthHeader || process.env.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER || undefined;
const baseRunDir = argv.runDir || process.env.RUN_DIR || '/n8n';
const n8nLicenseCert = argv.n8nLicenseCert || process.env.N8N_LICENSE_CERT || undefined;
const n8nLicenseActivationKey = process.env.N8N_LICENSE_ACTIVATION_KEY || undefined;
const n8nLicenseTenantId = argv.n8nLicenseTenantId || process.env.N8N_LICENSE_TENANT_ID || '1';
const envTag = argv.env || 'local';
const vus = argv.vus;
const duration = argv.duration;
const scenarioFilter = argv.scenarioFilter;
const hasN8nLicense = !!n8nLicenseCert || !!n8nLicenseActivationKey;
if (n8nSetupToUse === 'scaling-multi-main' && !hasN8nLicense) {
console.error(
'n8n license is required to run the multi-main scaling setup. Please provide N8N_LICENSE_CERT or N8N_LICENSE_ACTIVATION_KEY (and N8N_LICENSE_TENANT_ID if needed)',
);
process.exit(1);
}
if (!fs.existsSync(baseRunDir)) {
console.error(
`The run directory "${baseRunDir}" does not exist. Please specify a valid directory using --runDir`,
);
process.exit(1);
}
const runDir = path.join(baseRunDir, n8nSetupToUse);
fs.emptyDirSync(runDir);
const dockerComposeClient = new DockerComposeClient({
$: $({
cwd: composeFilePath,
verbose: true,
env: {
PATH: process.env.PATH,
N8N_VERSION: n8nTag,
N8N_LICENSE_CERT: n8nLicenseCert,
N8N_LICENSE_ACTIVATION_KEY: n8nLicenseActivationKey,
N8N_LICENSE_TENANT_ID: n8nLicenseTenantId,
N8N_ENCRYPTION_KEY,
BENCHMARK_VERSION: benchmarkTag,
K6_API_TOKEN: k6ApiToken,
BENCHMARK_RESULT_WEBHOOK_URL: resultWebhookUrl,
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: resultWebhookAuthHeader,
RUN_DIR: runDir,
MOCK_API_DATA_PATH: paths.mockApiDataPath,
},
}),
});
// Run the setup script if it exists
if (fs.existsSync(setupScriptPath)) {
const setupScript = await import(setupScriptPath);
await setupScript.setup({ runDir });
}
try {
const runnerServices = await discoverRunnerServices(dockerComposeClient);
await dockerComposeClient.$('up', '-d', '--remove-orphans', 'n8n', ...runnerServices);
const tags = Object.entries({
Env: envTag,
N8nVersion: n8nTag,
N8nSetup: n8nSetupToUse,
})
.map(([key, value]) => `${key}=${value}`)
.join(',');
const cliArgs = flagsObjectToCliArgs({
scenarioNamePrefix: n8nSetupToUse,
scenarioFilter,
vus,
duration,
tags,
});
await dockerComposeClient.$('run', 'benchmark', 'run', ...cliArgs);
} catch (error) {
console.error('An error occurred while running the benchmarks:');
console.error(error.message);
console.error('');
await printContainerStatus(dockerComposeClient);
throw error;
} finally {
await dumpLogs(dockerComposeClient);
await dockerComposeClient.$('down');
}
}
async function printContainerStatus(dockerComposeClient) {
console.error('Container statuses:');
await dockerComposeClient.$('ps', '-a');
}
async function dumpLogs(dockerComposeClient) {
console.info('Container logs:');
await dockerComposeClient.$('logs');
}
function printUsage() {
const availableSetups = getAllN8nSetups();
console.log('Usage: zx runForN8nSetup.mjs --runDir /path/for/n8n/data <n8n setup to use>');
console.log(` eg: zx runForN8nSetup.mjs --runDir /path/for/n8n/data ${availableSetups[0]}`);
console.log('');
console.log('Flags:');
console.log(
' --runDir <path> Directory to share with the n8n container for storing data. Default is /n8n',
);
console.log(' --n8nDockerTag <tag> Docker tag for n8n image. Default is latest');
console.log(
' --benchmarkDockerTag <tag> Docker tag for benchmark cli image. Default is latest',
);
console.log(' --k6ApiToken <token> K6 API token to upload the results');
console.log('');
console.log('Available setups:');
console.log(availableSetups.join(', '));
}
/**
* @returns {string[]}
*/
function getAllN8nSetups() {
return fs.readdirSync(paths.n8nSetupsDir);
}
function validateN8nSetup(givenSetup) {
const availableSetups = getAllN8nSetups();
if (!availableSetups.includes(givenSetup)) {
printUsage();
process.exit(1);
}
}
main();
@@ -0,0 +1,167 @@
#!/usr/bin/env zx
/**
* Script to run benchmarks on the cloud benchmark environment.
* This script will:
* 1. Provision a benchmark environment using Terraform.
* 2. Run the benchmarks on the VM.
* 3. Destroy the cloud environment.
*
* NOTE: Must be run in the root of the package.
*/
// @ts-check
import { sleep, which, $, tmpdir } from 'zx';
import path from 'path';
import { SshClient } from './clients/ssh-client.mjs';
import { TerraformClient } from './clients/terraform-client.mjs';
import { flagsObjectToCliArgs } from './utils/flags.mjs';
/**
* @typedef {Object} BenchmarkEnv
* @property {string} vmName
* @property {string} ip
* @property {string} sshUsername
* @property {string} sshPrivateKeyPath
*/
/**
* @typedef {Object} Config
* @property {boolean} isVerbose
* @property {string[]} n8nSetupsToUse
* @property {string} n8nTag
* @property {string} benchmarkTag
* @property {string} [k6ApiToken]
* @property {string} [resultWebhookUrl]
* @property {string} [resultWebhookAuthHeader]
* @property {string} [n8nLicenseCert]
* @property {string} [vus]
* @property {string} [duration]
* @property {string} [scenarioFilter]
*
* @param {Config} config
*/
export async function runInCloud(config) {
await ensureDependencies();
const terraformClient = new TerraformClient({
isVerbose: config.isVerbose,
});
const benchmarkEnv = await terraformClient.getTerraformOutputs();
await runBenchmarksOnVm(config, benchmarkEnv);
}
async function ensureDependencies() {
await which('terraform');
await which('az');
}
/**
* @param {Config} config
* @param {BenchmarkEnv} benchmarkEnv
*/
async function runBenchmarksOnVm(config, benchmarkEnv) {
console.log(`Setting up the environment...`);
const sshClient = new SshClient({
ip: benchmarkEnv.ip,
username: benchmarkEnv.sshUsername,
privateKeyPath: benchmarkEnv.sshPrivateKeyPath,
verbose: config.isVerbose,
});
await ensureVmIsReachable(sshClient);
const scriptsDir = await transferScriptsToVm(sshClient, config);
// Bootstrap the environment with dependencies
console.log('Running bootstrap script...');
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`);
// Give some time for the VM to be ready
await sleep(1000);
const failures = [];
for (const n8nSetup of config.n8nSetupsToUse) {
try {
await runBenchmarkForN8nSetup({
config,
sshClient,
scriptsDir,
n8nSetup,
});
} catch (error) {
console.error(`Benchmark failed for ${n8nSetup}:`, error.message);
failures.push(n8nSetup);
}
}
if (failures.length > 0) {
throw new Error(`Benchmarks failed for setups: ${failures.join(', ')}`);
}
}
/**
* @param {{ config: Config; sshClient: any; scriptsDir: string; n8nSetup: string; }} opts
*/
async function runBenchmarkForN8nSetup({ config, sshClient, scriptsDir, n8nSetup }) {
console.log(`Running benchmarks for ${n8nSetup}...`);
const runScriptPath = path.join(scriptsDir, 'run-for-n8n-setup.mjs');
const cliArgs = flagsObjectToCliArgs({
n8nDockerTag: config.n8nTag,
benchmarkDockerTag: config.benchmarkTag,
k6ApiToken: config.k6ApiToken,
resultWebhookUrl: config.resultWebhookUrl,
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
n8nLicenseCert: config.n8nLicenseCert,
scenarioFilter: config.scenarioFilter,
vus: config.vus,
duration: config.duration,
env: 'cloud',
});
const flagsString = cliArgs.join(' ');
await sshClient.ssh(`npx zx ${runScriptPath} ${flagsString} ${n8nSetup}`, {
// Test run should always log its output
verbose: true,
});
}
async function ensureVmIsReachable(sshClient) {
try {
await sshClient.ssh('echo "VM is reachable"');
} catch (error) {
console.error(`VM is not reachable: ${error.message}`);
console.error(
`Did you provision the cloud environment first with 'pnpm provision-cloud-env'? You can also run the benchmarks locally with 'pnpm run benchmark-locally'.`,
);
process.exit(1);
}
}
/**
* @returns Path where the scripts are located on the VM
*/
async function transferScriptsToVm(sshClient, config) {
const cwd = process.cwd();
const scriptsDir = path.resolve(cwd, './scripts');
const tarFilename = 'scripts.tar.gz';
const scriptsTarPath = path.join(tmpdir('n8n-benchmark'), tarFilename);
const $$ = $({ verbose: config.isVerbose });
// Compress the scripts folder
await $$`tar -czf ${scriptsTarPath} ${scriptsDir} -C ${cwd} ./scripts`;
// Transfer the scripts to the VM
await sshClient.scp(scriptsTarPath, `~/${tarFilename}`);
// Extract the scripts on the VM
await sshClient.ssh(`tar -xzf ~/${tarFilename}`);
return '~/scripts';
}
@@ -0,0 +1,73 @@
#!/usr/bin/env zx
/**
* Script to run benchmarks on the cloud benchmark environment.
* This script will:
* 1. Provision a benchmark environment using Terraform.
* 2. Run the benchmarks on the VM.
* 3. Destroy the cloud environment.
*
* NOTE: Must be run in the root of the package.
*/
// @ts-check
import { $ } from 'zx';
import path from 'path';
import { flagsObjectToCliArgs } from './utils/flags.mjs';
/**
* @typedef {Object} BenchmarkEnv
* @property {string} vmName
*/
const paths = {
scriptsDir: path.join(path.resolve('scripts')),
};
/**
* @typedef {Object} Config
* @property {boolean} isVerbose
* @property {string[]} n8nSetupsToUse
* @property {string} n8nTag
* @property {string} benchmarkTag
* @property {string} [runDir]
* @property {string} [k6ApiToken]
* @property {string} [resultWebhookUrl]
* @property {string} [resultWebhookAuthHeader]
* @property {string} [n8nLicenseCert]
* @property {string} [vus]
* @property {string} [duration]
* @property {string} [scenarioFilter]
*
* @param {Config} config
*/
export async function runLocally(config) {
const runScriptPath = path.join(paths.scriptsDir, 'run-for-n8n-setup.mjs');
const cliArgs = flagsObjectToCliArgs({
n8nDockerTag: config.n8nTag,
benchmarkDockerTag: config.benchmarkTag,
runDir: config.runDir,
vus: config.vus,
duration: config.duration,
scenarioFilter: config.scenarioFilter,
env: 'local',
});
try {
for (const n8nSetup of config.n8nSetupsToUse) {
console.log(`Running benchmarks for n8n setup: ${n8nSetup}`);
await $({
env: {
...process.env,
K6_API_TOKEN: config.k6ApiToken,
BENCHMARK_RESULT_WEBHOOK_URL: config.resultWebhookUrl,
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: config.resultWebhookAuthHeader,
N8N_LICENSE_CERT: config.n8nLicenseCert,
},
})`npx ${runScriptPath} ${cliArgs} ${n8nSetup}`;
}
} catch (error) {
console.error('An error occurred while running the benchmarks:');
console.error(error);
}
}
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env zx
/**
* Script to run benchmarks either on the cloud benchmark environment or locally.
* The cloud environment needs to be provisioned using Terraform before running the benchmarks.
*
* NOTE: Must be run in the root of the package.
*/
// @ts-check
import fs from 'fs';
import minimist from 'minimist';
import path from 'path';
import { runInCloud } from './run-in-cloud.mjs';
import { runLocally } from './run-locally.mjs';
const paths = {
n8nSetupsDir: path.join(path.resolve('scripts'), 'n8n-setups'),
};
async function main() {
const config = await parseAndValidateConfig();
const n8nSetupsToUse =
config.n8nSetupToUse === 'all' ? readAvailableN8nSetups() : [config.n8nSetupToUse];
console.log('Using n8n tag', config.n8nTag);
console.log('Using benchmark cli tag', config.benchmarkTag);
console.log('Using environment', config.env);
console.log('Using n8n setups', n8nSetupsToUse.join(', '));
console.log('');
if (config.env === 'cloud') {
await runInCloud({
benchmarkTag: config.benchmarkTag,
isVerbose: config.isVerbose,
k6ApiToken: config.k6ApiToken,
resultWebhookUrl: config.resultWebhookUrl,
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
n8nLicenseCert: config.n8nLicenseCert,
n8nTag: config.n8nTag,
n8nSetupsToUse,
vus: config.vus,
duration: config.duration,
scenarioFilter: config.scenarioFilter,
});
} else if (config.env === 'local') {
await runLocally({
benchmarkTag: config.benchmarkTag,
isVerbose: config.isVerbose,
k6ApiToken: config.k6ApiToken,
resultWebhookUrl: config.resultWebhookUrl,
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
n8nLicenseCert: config.n8nLicenseCert,
n8nTag: config.n8nTag,
runDir: config.runDir,
n8nSetupsToUse,
vus: config.vus,
duration: config.duration,
scenarioFilter: config.scenarioFilter,
});
} else {
console.error('Invalid env:', config.env);
printUsage();
process.exit(1);
}
}
function readAvailableN8nSetups() {
const setups = fs.readdirSync(paths.n8nSetupsDir);
return setups;
}
/**
* @typedef {Object} Config
* @property {boolean} isVerbose
* @property {'cloud' | 'local'} env
* @property {string} n8nSetupToUse
* @property {string} n8nTag
* @property {string} benchmarkTag
* @property {string} [k6ApiToken]
* @property {string} [resultWebhookUrl]
* @property {string} [resultWebhookAuthHeader]
* @property {string} [n8nLicenseCert]
* @property {string} [runDir]
* @property {string} [vus]
* @property {string} [duration]
* @property {string} [scenarioFilter]
*
* @returns {Promise<Config>}
*/
async function parseAndValidateConfig() {
const args = minimist(process.argv.slice(3), {
boolean: ['debug', 'help'],
});
if (args.help) {
printUsage();
process.exit(0);
}
const n8nSetupToUse = await getAndValidateN8nSetup(args);
const isVerbose = args.debug || false;
const n8nTag = args.n8nTag || process.env.N8N_DOCKER_TAG || 'latest';
const benchmarkTag = args.benchmarkTag || process.env.BENCHMARK_DOCKER_TAG || 'latest';
const k6ApiToken = args.k6ApiToken || process.env.K6_API_TOKEN || undefined;
const resultWebhookUrl =
args.resultWebhookUrl || process.env.BENCHMARK_RESULT_WEBHOOK_URL || undefined;
const resultWebhookAuthHeader =
args.resultWebhookAuthHeader || process.env.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER || undefined;
const n8nLicenseCert = args.n8nLicenseCert || process.env.N8N_LICENSE_CERT || undefined;
const runDir = args.runDir || undefined;
const env = args.env || 'local';
const vus = args.vus;
const duration = args.duration;
const scenarioFilter = args.scenarioFilter;
if (!env) {
printUsage();
process.exit(1);
}
return {
isVerbose,
env,
n8nSetupToUse,
n8nTag,
benchmarkTag,
k6ApiToken,
resultWebhookUrl,
resultWebhookAuthHeader,
n8nLicenseCert,
runDir,
vus,
duration,
scenarioFilter,
};
}
/**
* @param {minimist.ParsedArgs} args
*/
async function getAndValidateN8nSetup(args) {
// Last parameter is the n8n setup to use
const n8nSetupToUse = args._[args._.length - 1];
if (!n8nSetupToUse || n8nSetupToUse === 'all') {
return 'all';
}
const availableSetups = readAvailableN8nSetups();
if (!availableSetups.includes(n8nSetupToUse)) {
printUsage();
process.exit(1);
}
return n8nSetupToUse;
}
function printUsage() {
const availableSetups = readAvailableN8nSetups();
console.log(`Usage: zx scripts/${path.basename(__filename)} [n8n setup name]`);
console.log(` eg: zx scripts/${path.basename(__filename)}`);
console.log('');
console.log('Options:');
console.log(
` [n8n setup name] Against which n8n setup to run the benchmarks. One of: ${['all', ...availableSetups].join(', ')}. Default is all`,
);
console.log(
' --env Env where to run the benchmarks. Either cloud or local. Default is local.',
);
console.log(' --debug Enable verbose output');
console.log(' --n8nTag Docker tag for n8n image. Default is latest');
console.log(' --benchmarkTag Docker tag for benchmark cli image. Default is latest');
console.log(' --scenarioFilter Filter scenarios by name (case-insensitive)');
console.log(' --vus How many concurrent requests to make');
console.log(' --duration Test duration, e.g. 1m or 30s');
console.log(
' --k6ApiToken API token for k6 cloud. Default is read from K6_API_TOKEN env var. If omitted, k6 cloud will not be used',
);
console.log(
' --runDir Directory to share with the n8n container for storing data. Needed only for local runs.',
);
console.log('');
}
main().catch((error) => {
console.error('An error occurred while running the benchmarks:');
console.error(error);
process.exit(1);
});
@@ -0,0 +1,20 @@
// @ts-check
/**
* Converts an object of flags to an array of CLI arguments.
*
* @param {Record<string, string | undefined>} flags
*
* @returns {string[]}
*/
export function flagsObjectToCliArgs(flags) {
return Object.entries(flags)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
if (typeof value === 'string' && value.includes(' ')) {
return `--${key}="${value}"`;
} else {
return `--${key}=${value}`;
}
});
}
@@ -0,0 +1,26 @@
import { Command } from '@oclif/core';
import { testScenariosPath } from '@/config/common-flags';
import { ScenarioLoader } from '@/scenario/scenario-loader';
export default class ListCommand extends Command {
static description = 'List all available scenarios';
static flags = {
testScenariosPath,
};
async run() {
const { flags } = await this.parse(ListCommand);
const scenarioLoader = new ScenarioLoader();
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath);
console.log('Available test scenarios:');
console.log('');
for (const scenario of allScenarios) {
console.log('\t', scenario.name, ':', scenario.description);
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Command, Flags } from '@oclif/core';
import { testScenariosPath } from '@/config/common-flags';
import { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
import { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
import { ScenarioLoader } from '@/scenario/scenario-loader';
import type { K6Tag } from '@/test-execution/k6-executor';
import { K6Executor } from '@/test-execution/k6-executor';
import { ScenarioRunner } from '@/test-execution/scenario-runner';
export default class RunCommand extends Command {
static description = 'Run all (default) or specified test scenarios';
static flags = {
testScenariosPath,
scenarioFilter: Flags.string({
char: 'f',
description: 'Filter scenarios by name',
}),
scenarioNamePrefix: Flags.string({
description: 'Prefix for the scenario name',
default: 'Unnamed',
}),
n8nBaseUrl: Flags.string({
description: 'The base URL for the n8n instance',
default: 'http://localhost:5678',
env: 'N8N_BASE_URL',
}),
n8nUserEmail: Flags.string({
description: 'The email address of the n8n user',
default: 'benchmark-user@n8n.io',
env: 'N8N_USER_EMAIL',
}),
k6ExecutablePath: Flags.string({
doc: 'The path to the k6 binary',
default: 'k6',
env: 'K6_PATH',
}),
k6ApiToken: Flags.string({
doc: 'The API token for k6 cloud',
default: undefined,
env: 'K6_API_TOKEN',
}),
out: Flags.string({
description: 'The --out flag for k6',
default: undefined,
env: 'K6_OUT',
}),
resultWebhookUrl: Flags.string({
doc: 'The URL where the benchmark results should be sent to',
default: undefined,
env: 'BENCHMARK_RESULT_WEBHOOK_URL',
}),
resultWebhookAuthHeader: Flags.string({
doc: 'The Authorization header value for the benchmark results webhook',
default: undefined,
env: 'BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER',
}),
n8nUserPassword: Flags.string({
description: 'The password of the n8n user',
default: 'VerySecret!123',
env: 'N8N_USER_PASSWORD',
}),
tags: Flags.string({
char: 't',
description: 'Tags to attach to the run. Comma separated list of key=value pairs',
}),
vus: Flags.integer({
description: 'Number of concurrent requests to make',
default: 5,
}),
duration: Flags.string({
description: 'Duration of the test with a unit, e.g. 1m',
default: '1m',
}),
collectAppMetrics: Flags.boolean({
description: 'Collect app metrics from the /metrics endpoint during test runs',
default: false,
env: 'COLLECT_APP_METRICS',
}),
appMetricsPollInterval: Flags.integer({
description: 'App metrics polling interval in milliseconds',
default: 5000,
env: 'APP_METRICS_POLL_INTERVAL',
}),
};
async run() {
const { flags } = await this.parse(RunCommand);
const tags = await this.parseTags();
const scenarioLoader = new ScenarioLoader();
const scenarioRunner = new ScenarioRunner(
new N8nApiClient(flags.n8nBaseUrl),
new ScenarioDataFileLoader(),
new K6Executor({
duration: flags.duration,
vus: flags.vus,
k6Out: flags.out,
k6ExecutablePath: flags.k6ExecutablePath,
k6ApiToken: flags.k6ApiToken,
n8nApiBaseUrl: flags.n8nBaseUrl,
tags,
resultsWebhook: flags.resultWebhookUrl
? {
url: flags.resultWebhookUrl,
authHeader: flags.resultWebhookAuthHeader,
}
: undefined,
appMetricsPolling: flags.collectAppMetrics
? {
enabled: true,
intervalMs: flags.appMetricsPollInterval,
}
: undefined,
}),
{
email: flags.n8nUserEmail,
password: flags.n8nUserPassword,
},
flags.scenarioNamePrefix,
);
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath, flags.scenarioFilter);
await scenarioRunner.runManyScenarios(allScenarios);
}
private async parseTags(): Promise<K6Tag[]> {
const { flags } = await this.parse(RunCommand);
if (!flags.tags) {
return [];
}
return flags.tags.split(',').map((tag) => {
const [name, value] = tag.split('=');
return { name, value };
});
}
}
@@ -0,0 +1,6 @@
import { Flags } from '@oclif/core';
export const testScenariosPath = Flags.string({
description: 'The path to the scenarios',
default: 'scenarios',
});
@@ -0,0 +1,88 @@
import type { AxiosRequestConfig } from 'axios';
import { N8nApiClient } from './n8n-api-client';
export class AuthenticatedN8nApiClient extends N8nApiClient {
constructor(
apiBaseUrl: string,
private readonly authCookie: string,
) {
super(apiBaseUrl);
}
static async createUsingUsernameAndPassword(
apiClient: N8nApiClient,
loginDetails: {
email: string;
password: string;
},
): Promise<AuthenticatedN8nApiClient> {
const response = await apiClient.restApiRequest('/login', {
method: 'POST',
data: {
emailOrLdapLoginId: loginDetails.email,
password: loginDetails.password,
},
});
if (response.data === 'n8n is starting up. Please wait') {
await apiClient.delay(1000);
return await this.createUsingUsernameAndPassword(apiClient, loginDetails);
}
const cookieHeader = response.headers['set-cookie'];
const authCookie = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader;
if (!authCookie) {
throw new Error(
'Did not receive authentication cookie even tho login succeeded: ' +
JSON.stringify(
{
status: response.status,
headers: response.headers,
data: response.data,
},
null,
2,
),
);
}
return new AuthenticatedN8nApiClient(apiClient.apiBaseUrl, authCookie);
}
async get<T>(endpoint: string) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'GET',
});
}
async post<T>(endpoint: string, data: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'POST',
data,
});
}
async patch<T>(endpoint: string, data: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'PATCH',
data,
});
}
async delete<T>(endpoint: string) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'DELETE',
});
}
protected async authenticatedRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
return await this.restApiRequest<T>(endpoint, {
...init,
headers: {
...init.headers,
cookie: this.authCookie,
},
});
}
}
@@ -0,0 +1,28 @@
import type { Credential } from '@/n8n-api-client/n8n-api-client.types';
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
export class CredentialApiClient {
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
async getAllCredentials(): Promise<Credential[]> {
const response = await this.apiClient.get<{ count: number; data: Credential[] }>(
'/credentials',
);
return response.data.data;
}
async createCredential(credential: Credential): Promise<Credential> {
const response = await this.apiClient.post<{ data: Credential }>('/credentials', {
...credential,
id: undefined,
});
return response.data.data;
}
async deleteCredential(credentialId: Credential['id']): Promise<void> {
await this.apiClient.delete(`/credentials/${credentialId}`);
}
}
@@ -0,0 +1,30 @@
import type { DataTable } from '@/n8n-api-client/n8n-api-client.types';
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
export class DataTableApiClient {
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
async getAllDataTables(): Promise<DataTable[]> {
const response = await this.apiClient.get<{ data: { count: number; data: DataTable[] } }>(
'/data-tables-global',
);
return response.data.data.data;
}
async deleteDataTable(projectId: string, dataTableId: DataTable['id']): Promise<void> {
await this.apiClient.delete(`/projects/${projectId}/data-tables/${dataTableId}`);
}
async createDataTable(projectId: string, dataTable: DataTable): Promise<DataTable> {
const response = await this.apiClient.post<{ data: DataTable }>(
`/projects/${projectId}/data-tables`,
{
...dataTable,
},
);
return response.data.data;
}
}
@@ -0,0 +1,87 @@
import type { AxiosError, AxiosRequestConfig } from 'axios';
import axios from 'axios';
export class N8nApiClient {
constructor(
readonly apiBaseUrl: string,
private readonly healthEndpoint: string = '/healthz',
) {}
async waitForInstanceToBecomeOnline(): Promise<void> {
const START_TIME = Date.now();
const INTERVAL_MS = 1000;
const TIMEOUT_MS = 60_000;
while (Date.now() - START_TIME < TIMEOUT_MS) {
try {
const response = await axios.request<{ status: 'ok' }>({
url: `${this.apiBaseUrl}${this.healthEndpoint}`,
method: 'GET',
});
if (response.status === 200 && response.data.status === 'ok') {
return;
}
} catch {}
console.log(`n8n instance not online yet, retrying in ${INTERVAL_MS / 1000} seconds...`);
await this.delay(INTERVAL_MS);
}
throw new Error(`n8n instance did not come online within ${TIMEOUT_MS / 1000} seconds`);
}
async setupOwnerIfNeeded(loginDetails: { email: string; password: string }) {
const response = await this.restApiRequest<{ message: string }>('/owner/setup', {
method: 'POST',
data: {
email: loginDetails.email,
password: loginDetails.password,
firstName: 'Test',
lastName: 'User',
},
// Don't throw on non-2xx responses
validateStatus: () => true,
});
const responsePayload = response.data;
if (response.status === 200) {
console.log('Owner setup successful');
} else if (response.status === 400) {
if (responsePayload.message === 'Instance owner already setup')
console.log('Owner already set up');
} else if (response.status === 404) {
// The n8n instance setup owner endpoint not be available yet even tho
// the health endpoint returns ok. In this case we simply retry.
console.log('Owner setup endpoint not available yet, retrying in 1s...');
await this.delay(1000);
await this.setupOwnerIfNeeded(loginDetails);
} else {
throw new Error(
`Owner setup failed with status ${response.status}: ${responsePayload.message}`,
);
}
}
async restApiRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
try {
return await axios.request<T>({
...init,
url: this.getRestEndpointUrl(endpoint),
});
} catch (e) {
const error = e as AxiosError;
console.error(`[ERROR] Request failed ${init.method} ${endpoint}`, error?.response?.data);
throw error;
}
}
async delay(ms: number): Promise<void> {
return await new Promise((resolve) => setTimeout(resolve, ms));
}
protected getRestEndpointUrl(endpoint: string) {
return `${this.apiBaseUrl}/rest${endpoint}`;
}
}
@@ -0,0 +1,27 @@
/**
* n8n workflow. This is a simplified version of the actual workflow object.
*/
export type Workflow = {
id: string;
name: string;
versionId: string;
tags?: string[];
};
export type Credential = {
id: string;
name: string;
type: string;
};
export type DataTableColumn = {
name: string;
type: 'string' | 'number' | 'boolean' | 'date';
};
export type DataTable = {
id?: string;
projectId?: number;
name: string;
columns: DataTableColumn[];
};
@@ -0,0 +1,11 @@
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
export class ProjectApiClient {
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
async getPersonalProject(): Promise<string> {
const response = await this.apiClient.get<{ data: { id: string } }>('/projects/personal');
return response.data.data.id;
}
}
@@ -0,0 +1,38 @@
import type { Workflow } from '@/n8n-api-client/n8n-api-client.types';
import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client';
export class WorkflowApiClient {
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
async getAllWorkflows(): Promise<Workflow[]> {
const response = await this.apiClient.get<{ count: number; data: Workflow[] }>('/workflows');
return response.data.data;
}
async createWorkflow(workflow: unknown): Promise<Workflow> {
const response = await this.apiClient.post<{ data: Workflow }>('/workflows', workflow);
return response.data.data;
}
async activateWorkflow(workflow: Workflow): Promise<Workflow> {
const response = await this.apiClient.post<{ data: Workflow }>(
`/workflows/${workflow.id}/activate`,
{
versionId: workflow.versionId,
},
);
return response.data.data;
}
async archiveWorkflow(workflowId: Workflow['id']): Promise<void> {
await this.apiClient.post(`/workflows/${workflowId}/archive`, {});
}
async deleteWorkflow(workflowId: Workflow['id']): Promise<void> {
await this.apiClient.delete(`/workflows/${workflowId}`);
}
}
@@ -0,0 +1,75 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Workflow, Credential, DataTable } from '@/n8n-api-client/n8n-api-client.types';
import type { Scenario } from '@/types/scenario';
export type LoadableScenarioData = {
workflows: Workflow[];
credentials: Credential[];
dataTable: DataTable | null;
};
/**
* Loads scenario data files from FS
*/
export class ScenarioDataFileLoader {
async loadDataForScenario(scenario: Scenario): Promise<LoadableScenarioData> {
const workflows = await Promise.all(
scenario.scenarioData.workflowFiles?.map((workflowFilePath) =>
this.loadSingleWorkflowFromFile(path.join(scenario.scenarioDirPath, workflowFilePath)),
) ?? [],
);
const credentials = await Promise.all(
scenario.scenarioData.credentialFiles?.map((credentialFilePath) =>
this.loadSingleCredentialFromFile(path.join(scenario.scenarioDirPath, credentialFilePath)),
) ?? [],
);
const dataTable = scenario.scenarioData.dataTableFile
? this.loadSingleDataTableFromFile(
path.join(scenario.scenarioDirPath, scenario.scenarioData.dataTableFile),
)
: null;
return {
workflows,
credentials,
dataTable,
};
}
private loadSingleCredentialFromFile(credentialFilePath: string): Credential {
const fileContent = fs.readFileSync(credentialFilePath, 'utf8');
try {
return JSON.parse(fileContent) as Credential;
} catch (error) {
const e = error as Error;
throw new Error(`Failed to parse credential file ${credentialFilePath}: ${e.message}`);
}
}
private loadSingleWorkflowFromFile(workflowFilePath: string): Workflow {
const fileContent = fs.readFileSync(workflowFilePath, 'utf8');
try {
return JSON.parse(fileContent) as Workflow;
} catch (error) {
const e = error as Error;
throw new Error(`Failed to parse workflow file ${workflowFilePath}: ${e.message}`);
}
}
private loadSingleDataTableFromFile(dataTableFilePath: string): DataTable {
const fileContent = fs.readFileSync(dataTableFilePath, 'utf8');
try {
return JSON.parse(fileContent) as DataTable;
} catch (error) {
const e = error as Error;
throw new Error(`Failed to parse data table file ${dataTableFilePath}: ${e.message}`);
}
}
}
@@ -0,0 +1,90 @@
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'path';
import type { Scenario, ScenarioManifest } from '@/types/scenario';
export class ScenarioLoader {
/**
* Loads all scenarios from the given path
*/
loadAll(pathToScenarios: string, filter?: string): Scenario[] {
pathToScenarios = path.resolve(pathToScenarios);
const scenarioFolders = fs
.readdirSync(pathToScenarios, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const scenarios: Scenario[] = [];
for (const folder of scenarioFolders) {
if (filter && folder.toLowerCase() !== filter.toLowerCase()) {
continue;
}
const scenarioPath = path.join(pathToScenarios, folder);
const manifestFileName = `${folder}.manifest.json`;
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
if (!fs.existsSync(scenarioManifestPath)) {
console.warn(`Scenario at ${scenarioPath} is missing the ${manifestFileName} file`);
continue;
}
// Load the scenario manifest file
const [scenario, validationErrors] =
this.loadAndValidateScenarioManifest(scenarioManifestPath);
if (validationErrors) {
console.warn(
`Scenario at ${scenarioPath} has the following validation errors: ${validationErrors.join(', ')}`,
);
continue;
}
scenarios.push({
...scenario,
id: this.formScenarioId(scenarioPath),
scenarioDirPath: scenarioPath,
});
}
return scenarios;
}
private loadAndValidateScenarioManifest(
scenarioManifestPath: string,
): [ScenarioManifest, null] | [null, string[]] {
const [scenario, error] = this.loadScenarioManifest(scenarioManifestPath);
if (!scenario) {
return [null, [error]];
}
const validationErrors: string[] = [];
if (!scenario.name) {
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a name`);
}
if (!scenario.description) {
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a description`);
}
return validationErrors.length === 0 ? [scenario, null] : [null, validationErrors];
}
private loadScenarioManifest(
scenarioManifestPath: string,
): [ScenarioManifest, null] | [null, string] {
try {
const scenario = JSON.parse(
fs.readFileSync(scenarioManifestPath, 'utf8'),
) as ScenarioManifest;
return [scenario, null];
} catch (error) {
const message = error instanceof Error ? error.message : JSON.stringify(error);
return [null, `Failed to parse manifest ${scenarioManifestPath}: ${message}`];
}
}
private formScenarioId(scenarioPath: string): string {
return createHash('sha256').update(scenarioPath).digest('hex');
}
}
@@ -0,0 +1,81 @@
/**
* Polls the /metrics endpoint from an n8n instance to collect application metrics
* during benchmark test runs.
*
* A Poller can be started and stopped once. After it's stopped, it cannot be restarted.
* Instead, a new poller instance should be created.
*/
export class AppMetricsPoller {
private intervalId: NodeJS.Timeout | undefined = undefined;
private metricsData: string[] = [];
private isRunning = false;
private isStopped = false;
constructor(
private readonly metricsUrl: string,
private readonly pollIntervalMs: number = 5000,
) {}
/**
* Starts polling the metrics endpoint
*/
start() {
if (this.isRunning) {
throw new Error('Metrics poller is already running');
}
if (this.isStopped) {
throw new Error('Metrics poller has been stopped and cannot be restarted');
}
this.isRunning = true;
this.metricsData = [];
// Immediately poll once to get initial metrics
void this.pollMetrics();
// Set up interval polling
this.intervalId = setInterval(() => {
void this.pollMetrics();
}, this.pollIntervalMs);
}
/**
* Stops polling the metrics endpoint
*/
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
this.isRunning = false;
this.isStopped = true;
}
/**
* Gets all collected metrics data
*/
getMetricsData(): string[] {
return this.metricsData;
}
/**
* Polls the metrics endpoint once
*/
private async pollMetrics() {
try {
const response = await fetch(this.metricsUrl);
if (!response.ok) {
console.warn(`Failed to poll metrics: ${response.status} ${response.statusText}`);
return;
}
const metricsText = await response.text();
this.metricsData.push(metricsText);
} catch (error) {
console.warn(
`Error polling metrics: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -0,0 +1,192 @@
import fs from 'fs';
import assert from 'node:assert/strict';
import path from 'path';
import { $, which, tmpfile } from 'zx';
import { AppMetricsPoller } from '@/test-execution/app-metrics-poller';
import { buildTestReport, type K6Tag } from '@/test-execution/test-report';
import type { Scenario } from '@/types/scenario';
export type { K6Tag };
export type K6ExecutorOpts = {
k6ExecutablePath: string;
/** How many concurrent requests to make */
vus: number;
/** Test duration, e.g. 1m or 30s */
duration: string;
k6Out?: string;
k6ApiToken?: string;
n8nApiBaseUrl: string;
tags?: K6Tag[];
resultsWebhook?: {
url: string;
authHeader: string;
};
/** Configuration for polling app metrics during test runs */
appMetricsPolling?: {
enabled: boolean;
/** Poll interval in milliseconds. Defaults to 5000ms (5 seconds) */
intervalMs?: number;
};
};
export type K6RunOpts = {
/** Name of the scenario run. Used e.g. when the run is reported to k6 cloud */
scenarioRunName: string;
};
/**
* Flag for the k6 CLI.
* @example ['--duration', '1m']
* @example ['--quiet']
*/
type K6CliFlag = [string | number] | [string, string | number];
/**
* Executes test scenarios using k6
*/
export class K6Executor {
/**
* This script is dynamically injected into the k6 test script to generate
* a summary report of the test execution.
*/
private readonly handleSummaryScript = `
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
export function handleSummary(data) {
return {
stdout: textSummary(data),
'{{scenarioName}}.summary.json': JSON.stringify(data),
};
}
`;
constructor(private readonly opts: K6ExecutorOpts) {}
async executeTestScenario(scenario: Scenario, { scenarioRunName }: K6RunOpts) {
const augmentedTestScriptPath = this.augmentSummaryScript(scenario, scenarioRunName);
const runDirPath = path.dirname(augmentedTestScriptPath);
const flags: K6CliFlag[] = [
['--quiet'],
['--duration', this.opts.duration],
['--vus', this.opts.vus],
];
if (this.opts.k6Out) {
flags.push(['--out', this.opts.k6Out]);
} else if (!this.opts.resultsWebhook && this.opts.k6ApiToken) {
flags.push(['--out', 'cloud']);
}
const flattedFlags = flags.flat(2);
const k6ExecutablePath = await this.resolveK6ExecutablePath();
// Start app metrics polling if enabled
let metricsPoller: AppMetricsPoller | undefined;
if (this.opts.appMetricsPolling?.enabled) {
const metricsUrl = `${this.opts.n8nApiBaseUrl}/metrics`;
const intervalMs = this.opts.appMetricsPolling.intervalMs ?? 5000;
metricsPoller = new AppMetricsPoller(metricsUrl, intervalMs);
metricsPoller.start();
console.log(`Started polling app metrics from ${metricsUrl} every ${intervalMs}ms`);
}
try {
await $({
cwd: runDirPath,
env: {
API_BASE_URL: this.opts.n8nApiBaseUrl,
DATA_TABLE_ID: scenario.dataTableId,
K6_CLOUD_TOKEN: this.opts.k6ApiToken,
},
stdio: 'inherit',
})`${k6ExecutablePath} run ${flattedFlags} ${augmentedTestScriptPath}`;
} finally {
// Stop metrics polling regardless of test outcome
if (metricsPoller) {
metricsPoller.stop();
console.log('Stopped polling app metrics');
}
}
console.log('\n');
if (this.opts.resultsWebhook) {
const endOfTestSummary = this.loadEndOfTestSummary(runDirPath, scenarioRunName);
const appMetricsData = metricsPoller?.getMetricsData();
const testReport = buildTestReport(
scenario,
endOfTestSummary,
[
...(this.opts.tags ?? []),
{ name: 'Vus', value: this.opts.vus.toString() },
{ name: 'Duration', value: this.opts.duration.toString() },
],
appMetricsData,
);
await this.sendTestReport(testReport);
}
}
async sendTestReport(testReport: unknown) {
assert(this.opts.resultsWebhook);
const response = await fetch(this.opts.resultsWebhook.url, {
method: 'POST',
body: JSON.stringify(testReport),
headers: {
Authorization: this.opts.resultsWebhook.authHeader,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.warn(`Failed to send test summary: ${response.status} ${await response.text()}`);
}
}
/**
* Augments the test script with a summary script
*
* @returns Absolute path to the augmented test script
*/
private augmentSummaryScript(scenario: Scenario, scenarioRunName: string) {
const fullTestScriptPath = path.join(scenario.scenarioDirPath, scenario.scriptPath);
const testScript = fs.readFileSync(fullTestScriptPath, 'utf8');
const summaryScript = this.handleSummaryScript.replace('{{scenarioName}}', scenarioRunName);
const augmentedTestScript = `${testScript}\n\n${summaryScript}`;
const tempFilePath = tmpfile(`${scenarioRunName}.js`, augmentedTestScript);
return tempFilePath;
}
private loadEndOfTestSummary(dir: string, scenarioRunName: string): K6EndOfTestSummary {
const summaryReportPath = path.join(dir, `${scenarioRunName}.summary.json`);
const summaryReport = fs.readFileSync(summaryReportPath, 'utf8');
try {
return JSON.parse(summaryReport) as K6EndOfTestSummary;
} catch (error) {
throw new Error(`Failed to parse the summary report at ${summaryReportPath}`);
}
}
/**
* @returns Resolved path to the k6 executable
*/
private async resolveK6ExecutablePath(): Promise<string> {
const k6ExecutablePath = await which(this.opts.k6ExecutablePath, { nothrow: true });
if (!k6ExecutablePath) {
throw new Error(
'Could not find k6 executable based on your `PATH`. Please ensure k6 is available in your system and add it to your `PATH` or specify the path to the k6 executable using the `K6_PATH` environment variable.',
);
}
return k6ExecutablePath;
}
}
@@ -0,0 +1,255 @@
/**
Example JSON:
{
"options": {
"summaryTrendStats": ["avg", "min", "med", "max", "p(90)", "p(95)"],
"summaryTimeUnit": "",
"noColor": false
},
"state": { "isStdOutTTY": false, "isStdErrTTY": false, "testRunDurationMs": 23.374 },
"metrics": {
"http_req_tls_handshaking": {
"type": "trend",
"contains": "time",
"values": { "avg": 0, "min": 0, "med": 0, "max": 0, "p(90)": 0, "p(95)": 0 }
},
"checks": {
"type": "rate",
"contains": "default",
"values": { "rate": 1, "passes": 1, "fails": 0 }
},
"http_req_sending": {
"type": "trend",
"contains": "time",
"values": {
"p(90)": 0.512,
"p(95)": 0.512,
"avg": 0.512,
"min": 0.512,
"med": 0.512,
"max": 0.512
}
},
"http_reqs": {
"contains": "default",
"values": { "count": 1, "rate": 42.78257893385813 },
"type": "counter"
},
"http_req_blocked": {
"contains": "time",
"values": {
"avg": 1.496,
"min": 1.496,
"med": 1.496,
"max": 1.496,
"p(90)": 1.496,
"p(95)": 1.496
},
"type": "trend"
},
"data_received": {
"type": "counter",
"contains": "data",
"values": { "count": 269, "rate": 11508.513733207838 }
},
"iterations": {
"type": "counter",
"contains": "default",
"values": { "count": 1, "rate": 42.78257893385813 }
},
"http_req_waiting": {
"type": "trend",
"contains": "time",
"values": {
"p(95)": 18.443,
"avg": 18.443,
"min": 18.443,
"med": 18.443,
"max": 18.443,
"p(90)": 18.443
}
},
"http_req_receiving": {
"type": "trend",
"contains": "time",
"values": {
"avg": 0.186,
"min": 0.186,
"med": 0.186,
"max": 0.186,
"p(90)": 0.186,
"p(95)": 0.186
}
},
"http_req_duration{expected_response:true}": {
"type": "trend",
"contains": "time",
"values": {
"max": 19.141,
"p(90)": 19.141,
"p(95)": 19.141,
"avg": 19.141,
"min": 19.141,
"med": 19.141
}
},
"iteration_duration": {
"type": "trend",
"contains": "time",
"values": {
"avg": 22.577833,
"min": 22.577833,
"med": 22.577833,
"max": 22.577833,
"p(90)": 22.577833,
"p(95)": 22.577833
}
},
"http_req_connecting": {
"type": "trend",
"contains": "time",
"values": {
"avg": 0.673,
"min": 0.673,
"med": 0.673,
"max": 0.673,
"p(90)": 0.673,
"p(95)": 0.673
}
},
"http_req_failed": {
"type": "rate",
"contains": "default",
"values": { "rate": 0, "passes": 0, "fails": 1 }
},
"http_req_duration": {
"type": "trend",
"contains": "time",
"values": {
"p(90)": 19.141,
"p(95)": 19.141,
"avg": 19.141,
"min": 19.141,
"med": 19.141,
"max": 19.141
}
},
"data_sent": {
"type": "counter",
"contains": "data",
"values": { "count": 102, "rate": 4363.82305125353 }
}
},
"root_group": {
"name": "",
"path": "",
"id": "d41d8cd98f00b204e9800998ecf8427e",
"groups": [],
"checks": [
{
"name": "is status 200",
"path": "::is status 200",
"id": "548d37ca5f33793206f7832e7cea54fb",
"passes": 1,
"fails": 0
}
]
}
}
*/
type TrendStat = 'avg' | 'min' | 'med' | 'max' | 'p(90)' | 'p(95)';
type MetricType = 'trend' | 'rate' | 'counter';
type MetricContains = 'time' | 'default' | 'data';
interface TrendValues {
avg: number;
min: number;
med: number;
max: number;
'p(90)': number;
'p(95)': number;
}
interface RateValues {
rate: number;
passes: number;
fails: number;
}
interface CounterValues {
count: number;
rate: number;
}
interface K6TrendMetric {
type: 'trend';
contains: 'time';
values: TrendValues;
}
interface RateMetric {
type: 'rate';
contains: 'default';
values: RateValues;
}
interface K6CounterMetric {
type: 'counter';
contains: MetricContains;
values: CounterValues;
}
interface Options {
summaryTrendStats: TrendStat[];
summaryTimeUnit: string;
noColor: boolean;
}
interface State {
isStdOutTTY: boolean;
isStdErrTTY: boolean;
testRunDurationMs: number;
}
interface Metrics {
http_req_tls_handshaking: K6TrendMetric;
checks: RateMetric;
http_req_sending: K6TrendMetric;
http_reqs: K6CounterMetric;
http_req_blocked: K6TrendMetric;
data_received: K6CounterMetric;
iterations: K6CounterMetric;
http_req_waiting: K6TrendMetric;
http_req_receiving: K6TrendMetric;
'http_req_duration{expected_response:true}': K6TrendMetric;
iteration_duration: K6TrendMetric;
http_req_connecting: K6TrendMetric;
http_req_failed: RateMetric;
http_req_duration: K6TrendMetric;
data_sent: K6CounterMetric;
}
interface K6Check {
name: string;
path: string;
id: string;
passes: number;
fails: number;
}
interface RootGroup {
name: string;
path: string;
id: string;
groups: unknown[];
checks: K6Check[];
}
interface K6EndOfTestSummary {
options: Options;
state: State;
metrics: Metrics;
root_group: RootGroup;
}
@@ -0,0 +1,63 @@
/**
* Parses Prometheus metrics text format and extracts time series data
*/
export class PrometheusMetricsParser {
/**
* Extracts all values for a specific metric from collected metrics data
*/
static extractMetricValues(metricsData: string[], metricName: string): number[] {
const values: number[] = [];
for (const metricsText of metricsData) {
const lines = metricsText.split('\n');
for (const line of lines) {
// Skip comments and empty lines
if (line.startsWith('#') || line.trim() === '') {
continue;
}
// Parse metric line: metric_name{labels} value
// For example: n8n_nodejs_heap_size_total_bytes 149159936
// For simplicity, we'll just check if the line starts with the metric name
if (line.startsWith(metricName)) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const value = parseFloat(parts[parts.length - 1]);
if (!isNaN(value)) {
values.push(value);
}
}
}
}
}
return values;
}
/**
* Calculates statistics for a metric from collected data
*/
static calculateMetricStats(
metricsData: string[],
metricName: string,
): { max: number; avg: number; min: number; count: number } | null {
const values = this.extractMetricValues(metricsData, metricName);
if (values.length === 0) {
return null;
}
const max = Math.max(...values);
const min = Math.min(...values);
const sum = values.reduce((a, b) => a + b, 0);
const avg = sum / values.length;
return {
max,
min,
avg,
count: values.length,
};
}
}
@@ -0,0 +1,165 @@
import type { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
import { CredentialApiClient } from '@/n8n-api-client/credentials-api-client';
import { DataTableApiClient } from '@/n8n-api-client/data-table-api-client';
import type { Workflow, Credential, DataTable } from '@/n8n-api-client/n8n-api-client.types';
import { ProjectApiClient } from '@/n8n-api-client/project-api-client';
import { WorkflowApiClient } from '@/n8n-api-client/workflows-api-client';
import type { LoadableScenarioData } from '@/scenario/scenario-data-loader';
/**
* Imports scenario data into an n8n instance
*/
export class ScenarioDataImporter {
private readonly workflowApiClient: WorkflowApiClient;
private readonly credentialApiClient: CredentialApiClient;
private readonly dataTableApiClient: DataTableApiClient;
private readonly projectApiClient: ProjectApiClient;
constructor(n8nApiClient: AuthenticatedN8nApiClient) {
this.workflowApiClient = new WorkflowApiClient(n8nApiClient);
this.credentialApiClient = new CredentialApiClient(n8nApiClient);
this.dataTableApiClient = new DataTableApiClient(n8nApiClient);
this.projectApiClient = new ProjectApiClient(n8nApiClient);
}
private replaceValuesInObject(obj: unknown, searchText: string, targetText: string) {
if (Array.isArray(obj)) {
obj.map((item) => this.replaceValuesInObject(item, searchText, targetText));
} else if (typeof obj === 'object' && obj !== null) {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && value === searchText) {
(obj as Record<string, unknown>)[key] = targetText;
} else {
this.replaceValuesInObject(value, searchText, targetText);
}
}
}
}
async importTestScenarioData(data: LoadableScenarioData) {
const existingWorkflows = await this.workflowApiClient.getAllWorkflows();
const existingCredentials = await this.credentialApiClient.getAllCredentials();
const existingDataTables = await this.dataTableApiClient.getAllDataTables();
for (const credential of data.credentials) {
const createdCredential = await this.importCredentials({ existingCredentials, credential });
// We need to update the id and name of the credential in the workflows
for (const workflow of data.workflows) {
this.replaceValuesInObject(workflow, credential.id, createdCredential.id);
this.replaceValuesInObject(workflow, credential.name, createdCredential.name);
}
}
for (const workflow of data.workflows) {
await this.importWorkflow({ existingWorkflows, workflow });
}
let dataTableId: string | undefined;
if (data.dataTable) {
dataTableId = await this.importDataTable({ existingDataTables, dataTable: data.dataTable });
}
return { dataTableId };
}
/**
* Imports a single credential into n8n removing any existing credentials with the same name
* @param opts
* @returns
*/
private async importCredentials(opts: {
existingCredentials: Credential[];
credential: Credential;
}) {
const existingCredentials = this.findExistingCredentials(
opts.existingCredentials,
opts.credential,
);
if (existingCredentials.length > 0) {
for (const toDelete of existingCredentials) {
await this.credentialApiClient.deleteCredential(toDelete.id);
}
}
return await this.credentialApiClient.createCredential({
...opts.credential,
name: this.getBenchmarkCredentialName(opts.credential),
});
}
private async importDataTable(opts: { existingDataTables: DataTable[]; dataTable: DataTable }) {
const { existingDataTables, dataTable } = opts;
const projectId = await this.projectApiClient.getPersonalProject();
const existingTable = existingDataTables.find(
(dt) => dt.name === this.getBenchmarkDataTableName(dataTable),
);
if (existingTable) {
await this.dataTableApiClient.deleteDataTable(projectId, existingTable.id);
}
const { id } = await this.dataTableApiClient.createDataTable(projectId, {
...dataTable,
name: this.getBenchmarkDataTableName(dataTable),
});
return id;
}
/**
* Imports a single workflow into n8n removing any existing workflows with the same name
*/
private async importWorkflow(opts: { existingWorkflows: Workflow[]; workflow: Workflow }) {
const existingWorkflows = this.findExistingWorkflows(opts.existingWorkflows, opts.workflow);
if (existingWorkflows.length > 0) {
for (const toDelete of existingWorkflows) {
await this.workflowApiClient.archiveWorkflow(toDelete.id);
await this.workflowApiClient.deleteWorkflow(toDelete.id);
}
}
const createdWorkflow = await this.workflowApiClient.createWorkflow({
...opts.workflow,
name: this.getBenchmarkWorkflowName(opts.workflow),
});
return await this.workflowApiClient.activateWorkflow(createdWorkflow);
}
private findExistingCredentials(
existingCredentials: Credential[],
credentialToImport: Credential,
): Credential[] {
const benchmarkCredentialName = this.getBenchmarkCredentialName(credentialToImport);
return existingCredentials.filter(
(existingCredential) => existingCredential.name === benchmarkCredentialName,
);
}
private findExistingWorkflows(
existingWorkflows: Workflow[],
workflowToImport: Workflow,
): Workflow[] {
const benchmarkWorkflowName = this.getBenchmarkWorkflowName(workflowToImport);
return existingWorkflows.filter(
(existingWorkflow) => existingWorkflow.name === benchmarkWorkflowName,
);
}
private getBenchmarkCredentialName(credential: Credential) {
return `[BENCHMARK] ${credential.name}`;
}
private getBenchmarkWorkflowName(workflow: Workflow) {
return `[BENCHMARK] ${workflow.name}`;
}
private getBenchmarkDataTableName(dataTable: DataTable) {
return `[BENCHMARK] ${dataTable.name}`;
}
}
@@ -0,0 +1,76 @@
import { sleep } from 'zx';
import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
import { ScenarioDataImporter } from '@/test-execution/scenario-data-importer';
import type { Scenario } from '@/types/scenario';
import type { K6Executor } from './k6-executor';
/**
* Runs scenarios
*/
export class ScenarioRunner {
constructor(
private readonly n8nClient: N8nApiClient,
private readonly dataLoader: ScenarioDataFileLoader,
private readonly k6Executor: K6Executor,
private readonly ownerConfig: {
email: string;
password: string;
},
private readonly scenarioPrefix: string,
) {}
async runManyScenarios(scenarios: Scenario[]) {
console.log(`Waiting for n8n ${this.n8nClient.apiBaseUrl} to become online`);
await this.n8nClient.waitForInstanceToBecomeOnline();
console.log('Setting up owner');
await this.n8nClient.setupOwnerIfNeeded(this.ownerConfig);
const authenticatedN8nClient = await AuthenticatedN8nApiClient.createUsingUsernameAndPassword(
this.n8nClient,
this.ownerConfig,
);
const testDataImporter = new ScenarioDataImporter(authenticatedN8nClient);
for (const scenario of scenarios) {
await this.runSingleTestScenario(testDataImporter, scenario);
}
}
private async runSingleTestScenario(testDataImporter: ScenarioDataImporter, scenario: Scenario) {
const scenarioRunName = this.formTestScenarioRunName(scenario);
console.log('Running scenario:', scenarioRunName);
console.log('Loading and importing data');
const testData = await this.dataLoader.loadDataForScenario(scenario);
const { dataTableId } = await testDataImporter.importTestScenarioData(testData);
// Wait for 1s before executing the scenario to ensure that the workflows are activated.
// In multi-main mode it can take some time before the workflow becomes active.
await sleep(1000);
console.log('Executing scenario script');
await this.k6Executor.executeTestScenario(
{
...scenario,
dataTableId,
},
{
scenarioRunName,
},
);
}
/**
* Forms a name for the scenario by combining prefix and scenario name.
* The benchmarks are ran against different n8n setups, so we use the
* prefix to differentiate between them.
*/
private formTestScenarioRunName(scenario: Scenario) {
return `${this.scenarioPrefix}-${scenario.name}`;
}
}
@@ -0,0 +1,152 @@
import { nanoid } from 'nanoid';
import { PrometheusMetricsParser } from '@/test-execution/prometheus-metrics-parser';
import type { Scenario } from '@/types/scenario';
export type K6Tag = {
name: string;
value: string;
};
export type Check = {
name: string;
passes: number;
fails: number;
};
export type CounterMetric = {
type: 'counter';
count: number;
rate: number;
};
export type TrendMetric = {
type: 'trend';
'p(95)': number;
avg: number;
min: number;
med: number;
max: number;
'p(90)': number;
};
export type AppMetricStats = {
max: number;
avg: number;
min: number;
count: number;
};
export type AppMetricsReport = {
heapSizeTotal?: AppMetricStats;
heapSizeUsed?: AppMetricStats;
externalMemory?: AppMetricStats;
eventLoopLag?: AppMetricStats;
};
export type TestReport = {
runId: string;
ts: string; // ISO8601
scenarioName: string;
tags: K6Tag[];
metrics: {
iterations: CounterMetric;
dataReceived: CounterMetric;
dataSent: CounterMetric;
httpRequests: CounterMetric;
httpRequestDuration: TrendMetric;
httpRequestSending: TrendMetric;
httpRequestReceiving: TrendMetric;
httpRequestWaiting: TrendMetric;
};
checks: Check[];
appMetrics?: AppMetricsReport;
};
function k6CheckToCheck(check: K6Check): Check {
return {
name: check.name,
passes: check.passes,
fails: check.fails,
};
}
function k6CounterToCounter(counter: K6CounterMetric): CounterMetric {
return {
type: 'counter',
count: counter.values.count,
rate: counter.values.rate,
};
}
function k6TrendToTrend(trend: K6TrendMetric): TrendMetric {
return {
type: 'trend',
'p(90)': trend.values['p(90)'],
avg: trend.values.avg,
min: trend.values.min,
med: trend.values.med,
max: trend.values.max,
'p(95)': trend.values['p(95)'],
};
}
/**
* Builds an app metrics report from collected Prometheus metrics data
*/
export function buildAppMetricsReport(metricsData: string[]): AppMetricsReport {
const heapSizeTotal = PrometheusMetricsParser.calculateMetricStats(
metricsData,
'n8n_nodejs_heap_size_total_bytes',
);
const heapSizeUsed = PrometheusMetricsParser.calculateMetricStats(
metricsData,
'n8n_nodejs_heap_size_used_bytes',
);
const externalMemory = PrometheusMetricsParser.calculateMetricStats(
metricsData,
'n8n_nodejs_external_memory_bytes',
);
const eventLoopLag = PrometheusMetricsParser.calculateMetricStats(
metricsData,
'n8n_nodejs_eventloop_lag_seconds',
);
return {
...(heapSizeTotal && { heapSizeTotal }),
...(heapSizeUsed && { heapSizeUsed }),
...(externalMemory && { externalMemory }),
...(eventLoopLag && { eventLoopLag }),
};
}
/**
* Converts the k6 test summary to a test report
*/
export function buildTestReport(
scenario: Scenario,
endOfTestSummary: K6EndOfTestSummary,
tags: K6Tag[],
appMetricsData?: string[],
): TestReport {
const appMetrics = appMetricsData ? buildAppMetricsReport(appMetricsData) : undefined;
return {
runId: nanoid(),
ts: new Date().toISOString(),
scenarioName: scenario.name,
tags,
checks: endOfTestSummary.root_group.checks.map(k6CheckToCheck),
metrics: {
dataReceived: k6CounterToCounter(endOfTestSummary.metrics.data_received),
dataSent: k6CounterToCounter(endOfTestSummary.metrics.data_sent),
httpRequests: k6CounterToCounter(endOfTestSummary.metrics.http_reqs),
httpRequestDuration: k6TrendToTrend(endOfTestSummary.metrics.http_req_duration),
httpRequestSending: k6TrendToTrend(endOfTestSummary.metrics.http_req_sending),
httpRequestReceiving: k6TrendToTrend(endOfTestSummary.metrics.http_req_receiving),
httpRequestWaiting: k6TrendToTrend(endOfTestSummary.metrics.http_req_waiting),
iterations: k6CounterToCounter(endOfTestSummary.metrics.iterations),
},
...(appMetrics && { appMetrics }),
};
}
@@ -0,0 +1,33 @@
export type ScenarioData = {
/** Relative paths to the workflow files */
workflowFiles?: string[];
/** Relative paths to the credential files */
credentialFiles?: string[];
/** Relative paths to the data table files */
dataTableFile?: string;
};
/**
* Configuration that defines the benchmark scenario
*/
export type ScenarioManifest = {
/** The name of the scenario */
name: string;
/** A longer description of the scenario */
description: string;
/** Relative path to the k6 script */
scriptPath: string;
/** Data to import before running the scenario */
scenarioData: ScenarioData;
};
/**
* Scenario with additional metadata
*/
export type Scenario = ScenarioManifest & {
id: string;
/** Path to the directory containing the scenario */
scenarioDirPath: string;
/** ID of the data table created for the scenario, if any */
dataTableId?: string;
};
@@ -0,0 +1,9 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": [
"@n8n/typescript-config/tsconfig.common.json",
"@n8n/typescript-config/tsconfig.backend.json"
],
"compilerOptions": {
"rootDir": ".",
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src/**/*.ts"]
}