Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,118 @@ make run-server
# Endpoints: /health, /api/users, /api/stats, /api/echo
```

## Script Engine (Code-Based Load Testing)

Write load tests in your preferred language — like k6, but polyglot with chaos patterns built-in.

```bash
kar script test.star # Starlark (Python-like)
kar script test.js # JavaScript
kar script test.py # Python
kar script test.rb # Ruby
kar script test.star --vus 50 --duration 5m # Override VUs and duration
kar script test.star --dashboard # Enable real-time web dashboard
```

### Starlark (.star)

```python
scenario(
name = "api-load-test",
pattern = chaos(preset = "aggressive", spike_factor = 3.0),
vus = ramp([
stage("30s", 10), # Ramp to 10 VUs over 30s
stage("2m", 50), # Ramp to 50 VUs over 2m
stage("30s", 0), # Ramp down
]),
thresholds = {
"http_req_duration{p95}": "< 500ms",
"http_req_failed": "< 0.05",
},
)

def setup():
resp = http.post("http://api.example.com/auth", json={"user": "test"})
return {"token": resp.json()["token"]}

def default(data):
headers = {"Authorization": "Bearer " + data["token"]}
resp = http.get("http://api.example.com/products", headers=headers)
check(resp, {
"status 200": lambda r: r.status == 200,
"has items": lambda r: len(r.json()) > 0,
})
sleep(think_time("1s", "3s"))
```

### JavaScript (.js)

```javascript
scenario({
name: "api-load-test",
pattern: chaos({ preset: "moderate" }),
thresholds: {
"http_req_duration{p95}": "< 500ms",
},
});

function run(data) {
var resp = http.get("http://api.example.com/health");
check(resp, {
"status 200": function(r) { return r.status === 200; },
});
}
```

### Python (.py)

```python
from kar98k import scenario, chaos, http, check, sleep, think_time

scenario(name="api-load-test", pattern=chaos(preset="moderate"))

def default(data):
resp = http.get("http://api.example.com/health")
check(resp, {
"status 200": lambda r: r.status == 200,
"has status": lambda r: "status" in r.json(),
})
sleep(think_time("1s", "3s"))
```

### Ruby (.rb)

```ruby
require_relative "../sdk/ruby/kar98k"

scenario name: "api-load-test", pattern: chaos(preset: "moderate")

def default(data)
resp = Http.get("http://api.example.com/health")
check resp,
"status 200" => ->(r) { r.status == 200 }
sleep_dur think_time("1s", "3s")
end
```

### Real-Time Dashboard

![kar98k Dashboard](./assets/dashboard.png)

Enable with `--dashboard`:

```bash
kar script test.star --vus 20 --duration 5m --dashboard
# Dashboard: http://localhost:8888
```

Opens a web UI showing:
- Live RPS and latency graphs
- P95/P99 latency tracking
- Error rate and status codes
- Check pass/fail rates
- VU count and iteration progress

## Commands

| Command | Description |
Expand Down
Binary file added assets/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions examples/basic_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// basic_test.js - Basic load test example (JavaScript)

scenario({
name: "basic-health-check",
pattern: chaos({ preset: "gentle" }),
thresholds: {
"http_req_duration{p95}": "< 500ms",
"http_req_failed": "< 0.05",
},
});

// Main iteration function — called per VU
function run(data) {
var resp = http.get("http://localhost:8080/health");
check(resp, {
"status is 200": function(r) { return r.status === 200; },
});
}
42 changes: 42 additions & 0 deletions examples/basic_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""k6-style load test written in Python."""

import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "sdk", "python"))

from kar98k import scenario, chaos, http, check, sleep, think_time

# Configure scenario
scenario(
name="python-api-test",
pattern=chaos(preset="moderate", spike_factor=2.5),
thresholds={
"http_req_duration{p95}": "< 500ms",
"http_req_failed": "< 0.05",
},
)

# Setup — runs once
def setup():
return {"session": "py-session-abc"}

# Main iteration — runs per VU
def default(data):
# GET health
resp = http.get("http://localhost:8080/health")
check(resp, {
"health status 200": lambda r: r.status == 200,
"has status field": lambda r: "status" in r.json(),
})

sleep(think_time("100ms", "500ms"))

# GET users
resp = http.get("http://localhost:8080/api/users")
check(resp, {
"users status 200": lambda r: r.status == 200,
})

# Teardown — runs once at end
def teardown(data):
pass
33 changes: 33 additions & 0 deletions examples/basic_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env ruby
# k6-style load test written in Ruby.

require_relative "../sdk/ruby/kar98k"

scenario name: "ruby-api-test",
pattern: chaos(preset: "moderate", spike_factor: 2.0),
thresholds: {
"http_req_duration{p95}" => "< 500ms",
"http_req_failed" => "< 0.05"
}

def setup
{ "session" => "rb-session-xyz" }
end

def default(data)
# GET health
resp = Http.get("http://localhost:8080/health")
check resp,
"health status 200" => ->(r) { r.status == 200 },
"has status field" => ->(r) { r.json&.key?("status") }

sleep_dur think_time("100ms", "500ms")

# GET users
resp = Http.get("http://localhost:8080/api/users")
check resp,
"users status 200" => ->(r) { r.status == 200 }
end

def teardown(data)
end
16 changes: 16 additions & 0 deletions examples/basic_test.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# basic_test.star - Basic load test example (Starlark)

scenario(
name = "basic-health-check",
pattern = chaos(preset = "gentle"),
thresholds = {
"http_req_duration{p95}": "< 500ms",
"http_req_failed": "< 0.05",
},
)

def default(data):
resp = http.get("http://localhost:8080/health")
check(resp, {
"status is 200": lambda r: r.status == 200,
})
47 changes: 47 additions & 0 deletions examples/checkout_test.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# checkout_test.star - Multi-step user flow with chaos patterns

scenario(
name = "checkout-flow",
pattern = chaos(
preset = "aggressive",
spike_factor = 3.0,
),
vus = ramp([
stage("10s", 5),
stage("30s", 20),
stage("10s", 0),
]),
thresholds = {
"http_req_duration{p95}": "< 1000ms",
"http_req_failed": "< 0.1",
"checks": "> 0.9",
},
)

def setup():
resp = http.post("http://localhost:8080/api/echo", json = {
"action": "auth",
"user": "loadtest",
})
return {"session": "test-session-id"}

def default(data):
headers = {"X-Session": data["session"]}

# Step 1: List products
resp = http.get("http://localhost:8080/api/users", headers = headers)
check(resp, {
"list ok": lambda r: r.status == 200,
})

# Think time — compresses during chaos spikes
sleep(think_time("500ms", "2s"))

# Step 2: Get stats
resp = http.get("http://localhost:8080/api/stats", headers = headers)
check(resp, {
"stats ok": lambda r: r.status == 200,
})

def teardown(data):
http.post("http://localhost:8080/api/echo", json = {"action": "logout"})
49 changes: 47 additions & 2 deletions examples/echoserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,24 @@ func handleHealth(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&stats.TotalRequests, 1)
atomic.AddInt64(&stats.GetRequests, 1)

// Simulate occasional slow responses
// 10% chance of failure: 500 error + 1~3s delay
if rand.Float32() < 0.10 {
delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
time.Sleep(delay)
atomic.AddInt64(&stats.Errors, 1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": "internal server error",
"delay": delay.String(),
})
return
}

// 5% chance of slow response (no error)
if rand.Float32() < 0.05 {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
time.Sleep(time.Duration(200+rand.Intn(500)) * time.Millisecond)
}

w.Header().Set("Content-Type", "application/json")
Expand Down Expand Up @@ -171,7 +186,35 @@ func handleUserByID(w http.ResponseWriter, r *http.Request) {
}
}

func maybeFail(w http.ResponseWriter) bool {
roll := rand.Float32()
// 10% → 500 with 1~3s delay
if roll < 0.10 {
delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
time.Sleep(delay)
atomic.AddInt64(&stats.Errors, 1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "internal server error"})
return true
}
// 5% → 400
if roll < 0.15 {
atomic.AddInt64(&stats.Errors, 1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "bad request"})
return true
}
// 3% → slow (no error)
if roll < 0.18 {
time.Sleep(time.Duration(200+rand.Intn(500)) * time.Millisecond)
}
return false
}

func listUsers(w http.ResponseWriter, r *http.Request) {
if maybeFail(w) { return }
store.mu.RLock()
defer store.mu.RUnlock()

Expand Down Expand Up @@ -285,6 +328,8 @@ func handleEcho(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&stats.TotalRequests, 1)
atomic.AddInt64(&stats.PostRequests, 1)

if maybeFail(w) { return }

w.Header().Set("Content-Type", "application/json")

var body interface{}
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
Expand All @@ -44,6 +48,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.starlark.net v0.0.0-20260326113308-fadfc96def35 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
Expand Down
Loading
Loading