xk6sip

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

xk6-sip

k6 extension k6/x/sip for load testing and functional testing of PBXs and SIP servers with scripted subscribers: each VU owns real SIP devices that register, call each other through the system under test and check what arrives on the other side — signalling, audio and voice quality.

Status: REGISTER, INVITE/CANCEL/BYE with digest auth; RTP with G.711 (PCMU/PCMA), RFC 3550 loss/jitter, RFC 4733 DTMF and audio detection; call recording and voice quality scoring against a reference; hold/resume, blind and attended transfer (REFER, Replaces), PRACK (100rel) and session timers.

Voice quality and audio testing

Most SIP load tools stop at signalling: a call that got 200 OK counts as good even if nobody hears anything. xk6-sip checks the audio itself, in the same script, under load:

  • Audio presence and one-way audio detection — isHeard() checks the signal level, not just that RTP packets flow; rtp_audio_heard gives the share of call legs that heard the other side.
  • Voice quality score against a reference — compareAudio() aligns what a subscriber heard with the expected phrase and scores it 0..1, with drop-outs, a clipped first word and the level difference. It catches distorted audio, packet loss, crossed media (hearing the wrong party), echo and silence.
  • IVR prompt and announcement verification — compare what the PBX played (menu prompt, "subscriber unavailable", music on hold) with a recorded reference.
  • Call recording — saveRecording() writes a stereo WAV of what a subscriber heard and said; record: 'onFailure' keeps only the calls that failed, for analysis after a CI run.
  • RTP statistics per call leg — packet loss, RFC 3550 jitter, codec, DTMF (RFC 4733), exported as k6 metrics and to Prometheus/Grafana.
  • Voice quality on the dashboard — median and worst 5% of scores and the share of calls below a threshold, next to signalling and generator metrics.

See the audio quality functional test and compareAudio().

Compatibility

xk6-sip k6 Go
v0.1.x – v0.3.x v2.x (built and tested with v2.3.0) 1.26+

Build and run

xk6 build v2.3.0 --with github.com/Dmitry-Fedotov-Dev/xk6-sip=. --output bin/k6
go build -o bin/testpbx ./cmd/testpbx

bin/testpbx -addr 127.0.0.1:5070 -users 200 -csv examples/subscribers.csv
bin/k6 run examples/call.js

The same run with the Grafana dashboard (Prometheus and Grafana in Docker, k6 pushes metrics with remote write):

docker compose -f monitoring/docker-compose.yml up -d    # Linux host metrics: add --profile linux-host

K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9091/api/v1/write \
K6_FEATURES=native-histograms \
bin/k6 run -o experimental-prometheus-rw \
  --tag testid=run-1 --tag pbx_version=4.2.1 \
  -e SIP_METRICS_ADDR=127.0.0.1:6566 examples/call.js

Windows PowerShell:

docker compose -f monitoring/docker-compose.yml up -d
$env:K6_PROMETHEUS_RW_SERVER_URL = 'http://localhost:9091/api/v1/write'
$env:K6_FEATURES = 'native-histograms'
.\bin\k6.exe run -o experimental-prometheus-rw --tag testid=run-1 --tag pbx_version=4.2.1 `
  -e SIP_METRICS_ADDR=127.0.0.1:6566 examples\call.js

Open http://localhost:3001 and pick the run in Test run. testid names the run, pbx_version lets you compare PBX versions, SIP_METRICS_ADDR adds the k6 process's own CPU, memory and traffic. Stop the stack with docker compose -f monitoring/docker-compose.yml down; the data stays in its volumes. Details: Monitoring.

Timings below a millisecond are only reliable on Linux: Go's clock on Windows ticks in ~0.5 ms steps.

Script API

import sip from 'k6/x/sip';

sip.options({ registerRate: 50, expectTimeout: '30s' }); // init context, optional

// No network in init; REGISTER is sent on first use and refreshed.
// Unknown string fields (ext, onk, gw_num, ...) are the device's numbers.
const ua1 = new sip.Device({ device: 'phone1', registrar: 'sip:pbx:5060',
  user: 'a@domain', pass: 'secret', expires: 180, ext: '701' });

export default function () {
  const out = ua1.call({ callee: ua2, aon: 'ext' });   // returns at once
  const inc = ua2.expectCall({ caller: ua1, aon: 'ext', timeout: '5s' }); // Call or false
  inc.accept();
  out.expectConnected();        // true/false
  inc.hangup();                 // CANCEL, reject or BYE depending on state
  out.expectDisconnected();
}

export function teardown() { sip.shutdown(); } // unregister everything

Only expect* methods and isHeard wait, so one VU can drive both ends of a call. They return false on timeout instead of throwing.

Full reference with every option and an example per method: docs/ (k6-docs format).

Module Description
new sip.Device(options) SIP subscriber; device, registrar, proxy, user, authUser, pass, expires, register, displayName, sessionExpires, prack, media options; any other field is a number (ext, onk...)
sip.options(options) localIP, registerRate, ringTimeout (3m), expectTimeout (30s), trace, deviceTag, media defaults
sip.audio(data), sip.tone(freq?, dbfs?) audio sources for calls
sip.shutdown() hang up, unregister and close all devices; call in teardown()
Device Description
call({callee, aon, timeout, id, headers, ...media}) send INVITE, return the Call at once (or false)
expectCall({caller, aon, timeout}) wait for a matching incoming call (or false)
register(), isRegistered() REGISTER now / last result
identity(key), id a number of the device / its name
destroy() hang up, unregister, close; next use starts it again
Call Description
accept(), reject(code?, reason?), hangup() answer, decline (603), end in any state
expectRinging/Connected/Disconnected(timeout?) wait for the state
state(), status(), remote(), callId, id calling/ringing/connected/ended, final status, other party
howCompleted() {endedBy, status, reason, duration} once ended
trace() SIP ladder of this leg
codec(), isHeard(timeout?), mediaStats() media checks and RTP statistics
saveRecording(path), compareAudio(ref) WAV of what the leg heard and sent; similarity to a reference with drop-outs and clipped start (need record)
sendDTMF(digits, ms?), expectDTMF(digits, timeout?), receivedDTMF() RFC 4733 DTMF
hold(), unhold(), isOnHold(), isRemoteHold() re-INVITE hold
transfer(dest, aon?), attendedTransfer(consult) REFER, REFER with Replaces
expectTransferred(timeout?), expectReferredCall(timeout?) transfer result / call placed on REFER
Debugging a call

call.trace() returns the SIP ladder of one call leg: every message the device sent (->) or received (<-) for this call, with the time since the first one. Print it when a check fails:

const out = ua1.call({ callee: '1999' });
if (!check(out, { 'connected': (c) => c.expectConnected('5s') })) {
  console.warn(out.trace());
}
+0.000s  -> INVITE sip:1999@test.local SIP/2.0
+0.001s  <- SIP/2.0 407 Proxy Authentication Required
+0.002s  -> INVITE (with credentials)
+0.003s  <- SIP/2.0 404 Not Found

Only start lines are kept, so tracing is cheap under load. sip.options({ trace: true }) keeps whole messages with headers and SDP. howCompleted() tells who ended the call and why, and callId finds the call in PBX logs.

Media

Every call sends RTP by default: a 1 kHz tone, so the far end can check that audio arrives. Streams are paced by a shared scheduler (500 VUs / ~1000 concurrent streams / 50k packets/s use ~0.6 CPU core on Linux).

const hello = sip.audio(open('./hello.wav', 'b')); // 16-bit PCM, 8 kHz, mono
const ua = new sip.Device({ ..., codecs: 'PCMA,PCMU', audio: hello });

const out = ua1.call({ callee: ua2, media: false }); // signalling only
inc.isHeard('3s');                // audio from the other side arrived
out.codec();                       // 'PCMA'
out.sendDTMF('1#');                // RFC 4733
inc.expectDTMF('1#', '5s');
out.mediaStats();                  // {codec, sent, received, lost, jitter, heard, dtmf}

Media options (media, codecs, audio: sip.audio(...) | sip.tone(freq, dbfs) | 'silence', heardLevel, record, recordDir) can be set in sip.options(), per Device and per call.

sip.options({ record: 'onFailure', recordDir: 'records' }); // WAV of every call with a failed check

const B = new sip.Device({ ..., record: true });
const q = inc.compareAudio(hello);   // {score, offset, compared, gaps, clippedStart, gain}
check(q, { 'B hears A clearly': (q) => q && q.score >= 0.9 });
inc.saveRecording('records/b.wav');  // left: what B heard, right: what B sent

Recording is off by default. When on, it costs 32 KB of memory per second of each recorded leg (about 1 GB for 1,000 legs of 30 s at once), and one compareAudio() takes about 15 ms of CPU for a 30 s recording: under load, record and compare a sample of the calls.

Hold and transfer
inc.hold();                         // re-INVITE a=sendonly; out.isRemoteHold() === true
inc.unhold();

inc.transfer(ua3);                  // blind: REFER to ua3's ext (or '703', or (ua3, 'onk'))
inc.expectTransferred('10s');        // final NOTIFY says 2xx

const consult = ua2.call({ callee: ua3 }); /* ... answered ... */
inc.attendedTransfer(consult);      // REFER with Replaces

PBXs that handle REFER themselves (B2BUA, hosted PBX) keep the transferee's call and re-INVITE its media; when REFER reaches the endpoint instead, the device places the new call itself (call.expectReferredCall()) and answers INVITEs with Replaces automatically.

Device options: prack: true sends 180 reliably (RFC 3262) when the caller supports it (reliable 18x from the PBX are always PRACKed); sessionExpires: 1800 requests session timers (RFC 4028; refreshes and 422 are handled, a call whose peer stops refreshing is hung up).

Functional tests

examples/functional/ holds call-flow tests (basic call with DTMF, hold, blind and attended transfer, audio quality, reject/cancel/unknown number) for one VU and one iteration: any failed step stops the scenario, k6 exits non-zero and writes a JUnit report. CI runs all six against the test PBX on every push and keeps the reports and the WAV of failed audio checks as artifacts. audio-quality.js has each side say its own phrase from refs/ and checks that the other side hears it clearly, without drop-outs or a clipped first word, and doesn't hear itself; a failed audio check keeps the WAV of the call.

bin/testpbx -addr 127.0.0.1:5070 -users 3
bin/k6 run -e JUNIT=report.xml examples/functional/attended-transfer.js
# against a real PBX:
bin/k6 run -e REGISTRAR=sip:pbx:5060 -e A_USER=701@pbx -e A_PASS=... -e A_EXT=701 ... examples/functional/hold.js

Metrics

metric type tags
sip_requests counter method, status
sip_request_duration trend method, status
sip_failed_requests rate method (401/407 challenges are not failures)
sip_call_setup_time trend INVITE → 200
sip_post_dial_delay trend INVITE → first 18x
sip_call_success rate status
sip_call_duration trend ended_by
sip_invite_delivery_time trend caller's INVITE → callee receives it
sip_registrations counter
sip_invite_first_response_time trend INVITE → first response (100 Trying); above T1 = 500 ms it is retransmitted
sip_retransmissions counter method, kind (request/response), status
sip_expect_failed counter expect
rtp_packets_sent / _received / _lost counter codec, direction (per call leg)
rtp_jitter trend RFC 3550 interarrival jitter per leg
rtp_audio_heard rate legs that received audio; < 1 means one-way audio
rtp_audio_score trend similarity 0..1 of heard audio to a reference, per compareAudio()
rtp_audio_mismatch trend 1 - rtp_audio_score for dashboards: keeps scores near 1 apart in Prometheus native histograms
sip_calls counter phase: answered, ended (answered − ended = calls in progress)
sip_call_results counter result (success, failure, cancelled), status
rtp_legs counter codec, direction, heard (true/false)

sip.options({ deviceTag: true }) adds a device tag to all of them. The last three counters exist for dashboards: rates and trend percentiles are cumulative over the whole run in most outputs, counters can be turned into per-window rates.

Monitoring

monitoring/ holds Prometheus and Grafana with a ready dashboard: ASR, SEER (RFC 6076), ALOC and SLA shares, calls per second by status, setup time, post-dial delay, INVITE routing and first response time, SIP retransmissions, one-way audio, jitter, RTP loss, audio quality score (compareAudio), registrations, failed expectations, and the load generator: machine CPU, memory and network plus the k6 process's own CPU, memory and SIP/RTP traffic.

docker compose -f monitoring/docker-compose.yml up -d

K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9091/api/v1/write \
K6_FEATURES=native-histograms \
bin/k6 run -o experimental-prometheus-rw \
  --tag testid=run-1 --tag pbx_version=4.2.1 \
  -e SIP_METRICS_ADDR=127.0.0.1:6566 examples/call.js

SIP_METRICS_ADDR (or sip.options({ metricsAddr }) in any script) makes k6 serve its own resource usage at /metrics. Machine metrics come from node-exporter (--profile linux-host) on Linux, or from windows_exporter started on a Windows host with --collectors.enabled=cpu,memory,net,os,system --web.listen-address=127.0.0.1:9182.

Open http://localhost:3001 (no login; the stack is for local use and binds to 127.0.0.1). Native histograms give percentiles per time window, so a degradation in the middle of a long run is visible. testid and pbx_version tags let you pick a run and compare PBX versions. Panels, how to read them and limitations: docs/.../monitoring.md.

xk6-sip dashboard

Layout

  • engine/ – SIP core, independent of k6 (sipgo transactions + own dialog layer)
  • media/ – RTP: G.711, scheduler, statistics, DTMF, audio detection
  • testpbx/, cmd/testpbx – minimal registrar/B2BUA for tests and examples
  • repo root – the k6 adapter

Documentation

Overview

Package xk6sip is the k6 extension k6/x/sip. It exposes the SIP engine to k6 scripts and turns engine events into k6 metrics.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ModuleInstance

type ModuleInstance struct {
	// contains filtered or unexported fields
}

func (*ModuleInstance) Exports

func (mi *ModuleInstance) Exports() modules.Exports

type RootModule

type RootModule struct {
	// contains filtered or unexported fields
}

RootModule is shared by all VUs; it owns the single SIP engine of the process so that engine-wide limits (register rate) apply to the whole test.

func New

func New() *RootModule

func (*RootModule) NewModuleInstance

func (r *RootModule) NewModuleInstance(vu modules.VU) modules.Instance

Directories

Path Synopsis
cmd
testpbx command
Command testpbx runs the test registrar/B2BUA for local k6 runs and writes the generated subscribers to a CSV file for the scripts.
Command testpbx runs the test registrar/B2BUA for local k6 runs and writes the generated subscribers to a CSV file for the scripts.
docs
export command
Command export copies the k6/x/sip reference to a grafana/k6-docs checkout, converting it from GitHub Markdown to the k6-docs Hugo format: links to .md files become page URLs and examples are wrapped in {{< code >}}.
Command export copies the k6/x/sip reference to a grafana/k6-docs checkout, converting it from GitHub Markdown to the k6-docs Hugo format: links to .md files become page URLs and examples are wrapped in {{< code >}}.
Package engine is the SIP core: subscribers (Device), calls and their timings.
Package engine is the SIP core: subscribers (Device), calls and their timings.
Package media is the RTP side of a call: G.711 audio sent from a shared scheduler, RFC 3550 receive statistics, RFC 4733 DTMF and detection of whether any audio arrives at all.
Package media is the RTP side of a call: G.711 audio sent from a shared scheduler, RFC 3550 receive statistics, RFC 4733 DTMF and detection of whether any audio arrives at all.
Package testpbx is a minimal registrar + B2BUA for tests and examples.
Package testpbx is a minimal registrar + B2BUA for tests and examples.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL