Contact Us

xk6-SIP: Telephony as Code

The 2026 paradox

It is 2026. Web services are tested with cloud orchestrators: thousands of virtual users spin up from a pipeline, metrics land in Prometheus, and an alert catches degradation before a customer notices. Telephony — which still runs call centers, banks, emergency services, and dispatch desks — often checks a PBX release the same way it did twenty years ago: a tester picks up two phones and calls themselves. Load comes from SIPp with XML scenarios, where each side of a call is a separate process, and checking the media is another awkward chunk of scenario.

xk6-sip is my attempt to close this gap. It is a k6/x/sip extension for Grafana k6 in which a call is described in JavaScript: subscribers register, call each other through the PBX under test, answer, transfer, hold, send DTMF, and verify they can hear each other. The same script works as a single-call functional test and as a load test of thousands of concurrent conversations.

In effect this is an infrastructure shift: SIP communications start being described the way infrastructure is described in IaC. The scenario lives in Git, goes through review, runs from CI, and leaves metrics behind instead of a verbal report that "it seems to work".

The extension has been submitted to the official Grafana Labs k6 extension registry and is under review there. The code is open under Apache-2.0: github.com/Dmitry-Fedotov-Dev/xk6-sip.

I spent four years in telephony, going from a manual QA intern on the core team to Performance Engineer on the product. The idea of describing a test as subscriber actions came from an internal tool I wrote. Everything else — code, load, media, metrics, the SIP stack — was written from scratch in xk6-sip.

What this article covers:

  • Why a new tool when SIPp and free hands exist — manual testing, SIPp, and xk6-sip compared on the main criteria.
  • A call as code — a full call test, the rule "actions never wait, only expectations wait", and why JavaScript that looks synchronous does not hold up the load.
  • Call test patterns — caller ID, busy and no answer, two-way audibility, DTMF and IVR, hold, transfers, pickup.
  • One scenario for both regression and load — JUnit and Xray in CI, Little's law for sizing VUs, and why constant-arrival-rate is more honest than ramping-vus.
  • Economics — how many hours and how much money automation saves, and why it takes the routine rather than the job.
  • Under the hood — a custom dialog layer over sipgo, memory per subscriber, the RTP scheduler, and a 1000-media-stream benchmark.
  • Project future — dashboards, a benchmark catalog by PBX version, and an automated synthetic-call farm with product-version health monitoring and anomaly alerting.

Why a new tool when SIPp exists

SIPp produces signaling load well; manual calling is more reliable for exercising logic and judging what is audible in the handset. xk6-sip combines both properties in one script and adds what neither has: metrics and CI integration.

Criterion 🧍 Manual testing 📜 SIPp (industry standard) 🚀 xk6-sip
Description paradigm a test case for a human SIP dialogs in XML subscriber actions in JavaScript
Synchronizing the sides 1–3 people with phones separate UAC and UAS processes, manual synchronization both sides in one JS thread
Media analysis (RTP) by ear RTP check (since 3.7) in each side's XML scenario JS method isHeard(), loss and jitter per RFC 3550
Scaling only by hiring more people high 1000 RTP streams on 62% of one CPU core and 222 MB RAM
CI/CD integration manual checkmarks in a tracker external script glue out of the box: JUnit, thresholds, k6 exit code
Metrics and observability none statistics in CSV k6 metrics → Prometheus and Grafana

The main difference is in the first row. In SIPp you write protocol messages; in xk6-sip you write human behavior. The extension takes care of the protocol: digest authentication, CANCEL, re-INVITE, PRACK, session timers, REFER, and Replaces. So the audio check here is not a separate scenario construct — it is an ordinary test step next to caller ID, hold, and transfer, and under load it becomes a metric.

For completeness: the OpenSIPS team has SIPssert, a framework for functional testing of SIP servers in a Docker environment. It brings up the whole rig from containers (OpenSIPS, Asterisk, databases, RTPProxy) and can check the server itself, for example through MI commands. Internally it uses the same SIPp, so it complements xk6-sip rather than competing with it: SIPssert is about the server and its configuration, xk6-sip is about what the subscriber sees and hears, and about load.

A call as code

Here is the full test: subscriber A calls B, B sees the correct caller ID and answers, both hear each other, and A hangs up.

import sip from 'k6/x/sip';
import { check } from 'k6';

const A = new sip.Device({ device: 'phone1', registrar: 'sip:pbx:5060', user: '701@pbx', pass: 'secret', ext: '701' });
const B = new sip.Device({ device: 'phone2', registrar: 'sip:pbx:5060', user: '702@pbx', pass: 'secret', ext: '702' });

export default function () {
  const out = A.call({ callee: B });                  // action: INVITE sent, control returned immediately
  const inc = B.expectCall({ caller: A, timeout: '10s' }); // expectation: wait for the inbound call
  if (!check(inc, { 'inbound call arrived from CID 701': (c) => c })) return;

  inc.accept();
  check(out, {
    'connected': (c) => c.expectConnected('5s'),
    'A hears B':   (c) => c.isHeard('3s'),
  });
  check(inc, { 'B hears A': (c) => c.isHeard('3s') });

  out.hangup();
  check(inc, { 'B received BYE': (c) => c.expectDisconnected('5s') });
}

export function teardown() { sip.shutdown(); }

The whole scenario rests on one rule: an action never waits for a scenario step on another device; only expectations wait. That is what lets a single k6 virtual user (VU) play both sides of the call.

How it works

The code looks synchronous, but the network in it is asynchronous. JavaScript in k6 runs on sobek, an interpreter written in Go, a fork of goja. Each VU has its own runtime, and the script in it runs line by line. The SIP stack, transactions, timers, and RTP live in goroutines of the engine and work independently of what JS is doing at the moment.

  1. A.call() — the action. It passes the INVITE to the engine and immediately returns the call object, without waiting for an answer. From there, authentication, 100 Trying, and 180 Ringing are handled by the call's goroutine.
  2. B.expectCall() — the expectation. It blocks only this VU until the engine puts an inbound INVITE matching the conditions into the channel, or the timeout expires. On timeout it returns false, not an exception.
  3. If call() waited for the answer, the test would hang: B cannot answer until JS reaches inc.accept(). So every action — call, accept, hangup, hold, transfer, sendDTMF — returns control immediately, and only expect* and isHeard methods wait.

Why not callbacks or promises

In Node.js the same scenario would turn into event subscriptions — on('invite'), on('ringing') — plus an await chain with Promise.race against a timer at every step. The order of events would have to be reconstructed from handler code. Here the test reads like an Xray test case: step — expected result — step.

Performance does not suffer for it. A blocked VU holds no OS thread: it is a goroutine asleep on a channel, and the Go scheduler hands the core to other VUs in the meantime. Thousands of parallel calls are thousands of sleeping goroutines, not thousands of threads.

The result is that tests become ordinary code: they get reviewed in a merge request, common steps get extracted into a library, versions get diffed. When a check fails, out.trace() prints the SIP message ladder for that call, so there is no need to open Wireshark.

For example, A calls a number that does not exist on the PBX:

const out = A.call({ callee: '1999' });
if (!check(out, { 'A connected': (c) => c.expectConnected('5s') })) {
  console.warn(out.trace());
}
+0.000s  -> INVITE sip:[email protected] 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

The -> arrow means the message was sent, <- means received, and time counts from the first message. You can see right away that authentication passed and routing rejected the call. Dialog events land in the ladder too — here is what a call looks like where B put A on hold and brought them back:

+0.000s  -> INVITE sip:[email protected] SIP/2.0
+0.000s  <- SIP/2.0 407 Proxy Authentication Required
+0.000s  -> INVITE (with credentials)
+0.001s  <- SIP/2.0 100 Trying
+0.001s  <- SIP/2.0 180 Ringing
+0.002s  <- SIP/2.0 200 OK
+0.002s  -> ACK
+0.002s  <- INVITE sip:[email protected]:59161 SIP/2.0
+0.002s  <- re-INVITE: remote sendonly
+0.002s  -> SIP/2.0 200 OK
+0.003s  <- re-INVITE: remote sendrecv
+0.003s  -> BYE
+0.003s  <- SIP/2.0 200 OK (BYE)

By default only start lines are stored, so the trace costs almost nothing even under load. sip.options({ trace: true }) adds the full message under each line, with headers and SDP. Wireshark remains for dissecting the RTP packets themselves.

Call test patterns

Each pattern below takes a few lines and works both as a functional test and under load. The base call, caller ID, rejection, no answer, media, DTMF, hold, and both transfers are covered by the extension's tests against a test PBX. That PBX ships in the repository, so there is no need to spin up your own Asterisk for debugging. Forwarding, IVR, and pickup are features of a specific PBX: for those, a scenario is shown that you run against yours.

Routing and caller ID

In every pattern below, Alice, Bob, and Carol are devices of a single k6 VU. The PBX and everything behind it are the system under test.

// A dials B's public number, and B must see A's public number (CLI), not the extension
const out = A.call({ callee: B, aon: 'gw_num' });
const inc = B.expectCall({ caller: A, aon: 'onk', timeout: '10s' });

In call() the aon parameter is which number to dial for the callee; in expectCall() it is which caller number should be identified. The numbers come from a CSV of PBX subscribers. Under load, a wrong caller ID lands in sip_expect_failed{expect:call}, and sip_invite_delivery_time shows how long the PBX took to carry the INVITE from A to B. SIPp cannot compute this metric: its sides live in separate processes.

Busy, no answer, and forwarding

// B is busy — the PBX must forward the call to C
const out = A.call({ callee: B });
const busy = B.expectCall({ caller: A, aon: 'ext' });
if (busy) busy.reject(486, 'Busy Here');
const fwd = C.expectCall({ caller: A, aon: 'ext', timeout: '10s' });
check(fwd, { 'busy-transfer forwarded': (c) => c !== false });

// B does not answer — A cancels the call after 20 seconds (CANCEL)
const out2 = A.call({ callee: B, timeout: '20s' });
B.expectCall({ caller: A, aon: 'ext' });
check(out2.howCompleted(), { 'cancelled by A': (c) => out2.expectDisconnected('25s') && c.status === 487 });

Media: codec and two-way audibility

const A = new sip.Device({ ...subs[0], codecs: 'PCMA', audio: sip.audio(open('./greeting.wav', 'b')) });
// ...
check(out.codec(), { 'agreed on PCMA': (c) => c === 'PCMA' });
check(inc.isHeard('3s'), { 'B hears A': (ok) => ok });
check(out.isHeard('3s'), { 'A hears B': (ok) => ok });

Every call sends real RTP, and the receiver uses isHeard() to verify the sound arrived. Under load this becomes the threshold rtp_audio_heard: ['rate>0.99'] — the share of calls without one-way audibility. It is the most unpleasant failure in telephony: on signaling the call succeeded, but the person hears nothing.

DTMF and voice menus

// Call to the IVR, option "1" — connect to employee B
const out = A.call({ callee: '8800' });
check(out.expectConnected('10s') && out.isHeard('5s'), { 'IVR plays the greeting': (ok) => ok });
out.sendDTMF('1');
const inc = B.expectCall({ caller: A, aon: 'onk', timeout: '15s' });
check(inc, { 'menu connected to the agent': (c) => c !== false });

DTMF goes out per RFC 4733, and inc.expectDTMF('123#') checks that the PBX passes the tones between the two ends.

Hold and transfers

This pattern is for a PBX that performs the transfer itself. If the REFER reaches Alice, her device sends the INVITE with Replaces to Carol on its own.

// Attended transfer: B holds A, consults C, and connects them
check(inc.hold(), { 'B put A on hold': (ok) => ok && out.isRemoteHold() });
const consult = B.call({ callee: C });
const atC = C.expectCall({ caller: B, aon: 'ext' });
if (atC) atC.accept();
consult.expectConnected('10s');
inc.attendedTransfer(consult);
check(inc.expectTransferred('10s'), { 'A connected to C': (ok) => ok });

// Blind transfer — one line: inc.transfer(C)

Both PBX models are supported. Hosted PBXs and centrex perform the transfer themselves. Proxies pass the REFER through to the endpoint — then the new call is placed by the device itself (out.expectReferredCall()), and it answers an INVITE with Replaces automatically, like a real phone.

Pickup and service codes

// Ringing at B, C picks up with the pickup code
const out = A.call({ callee: B });
B.expectCall({ caller: A, aon: 'ext' });
const pick = C.call({ aon: '*6' + B.identity('ext') });
check(pick.expectConnected('10s') && out.isHeard('3s'), { 'A is talking to C': (ok) => ok });

aon accepts any dial string, so any PBX service code can be checked the same way.

One scenario for both regression and load

Any pattern above is a default function. The options decide how it runs.

Regression in CI. One VU, one run, every check must pass:

import { jUnit, textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';

export const options = {
  vus: 1, iterations: 1,
  thresholds: { checks: ['rate==1'] },   // any failed step means a non-zero exit code
};
export function handleSummary(data) {
  return { 'junit.xml': jUnit(data), stdout: textSummary(data) };
}

Any CI understands this without glue, and Xray imports JUnit and sets test case statuses itself. Pointing the same scenarios at a production PBX is done with environment variables.

Load — the same function, different options:

export const options = {
  scenarios: {
    caps: { executor: 'constant-arrival-rate', rate: 20, timeUnit: '1s',   // 20 new calls per second
            duration: '10m', preAllocatedVUs: 1300 },
  },
  thresholds: {
    sip_call_success:    ['rate>0.99'],
    sip_call_setup_time: ['p(95)<300'],
    rtp_audio_heard:     ['rate>0.99'],
  },
};

How many VUs you need: Little's law

The VU count is not picked by eye; queueing theory supplies it. Little's law links the average number of requests in the system L, their arrival rate λ, and the time they spend W:

Little's law

In telephony the request is a call, λ is CAPS (call attempts per second), and W is the time from INVITE to BYE. Twenty calls per second lasting 60 seconds give 1200 concurrent calls. One VU drives one call and holds two subscribers, so you need at least 1200 VUs and 2400 subscribers in the CSV. The headroom in preAllocatedVUs: 1300 covers call setup and teardown. The same formula works in reverse: if a PBX license provides 500 channels and the average conversation lasts 3 minutes, the sustainable rate is at most 2.8 calls per second.

Why constant-arrival-rate and not ramping-vus

ramping-vus is a closed model: each VU starts a new call only after finishing the previous one. When the PBX starts to slow down and answers an INVITE not in 100 ms but in 5 seconds, the generator lowers the rate itself and gives it a breather. The degradation hides: this is classic coordinated omission.

Real subscribers do not behave that way: a bank customer does not wait for the previous call to finish. constant-arrival-rate is an open model: k6 launches exactly λ calls per second regardless of how the PBX is coping. If the system cannot keep up, W grows, L grows with it, and k6 reports it honestly with the dropped_iterations metric. That is how you find throughput: the ceiling is the CAPS at which call setup time stops being flat. ramping-arrival-rate does the same in steps.

Metrics as operations questions

Question Metric
Does the PBX connect calls, and with which failure codes sip_call_success, tag status
How fast the subscriber hears ringback and an answer sip_post_dial_delay, sip_call_setup_time
How long the PBX routes the call sip_invite_delivery_time
How many calls went to the wrong place or with a wrong number sip_expect_failed{expect:call}
Whether people hear each other, media quality rtp_audio_heard, rtp_jitter, rtp_packets_lost
Whether the registrar holds up sip_request_duration{method:REGISTER}, sip_registrations

These are all ordinary k6 metrics: they can be set as thresholds and flow into Prometheus through the standard -o experimental-prometheus-rw output.

Economics: how much this saves

On a typical PBX product, automating call checks saves about 146 person-hours per month and about 2.6 million ₽ of payroll per year. The calculation below is step by step; the numbers are my own estimate from experience — substitute yours.

1. The starting point

  • Regression is 300 call test cases; an experienced tester runs up to 100 cases per day.
  • One release per week and two full runs per release: fixes land during testing.
  • Every working day brings features that need at least 40 cases of smoke and functional checks.

Regression per month: 4 releases × 2 runs × 300 cases = 2400 cases, or 192 hours of manual labor. Smoke and features add another 67 hours. Total 259 hours, 1.6 FTE just to make calls and tick boxes.

2. Hidden costs

Hours spent dialing are the visible part. There is an invisible one too:

  • Time to release. Manual regression takes three days of one tester's time, and every fix pushes the release out by the same amount.
  • Triage of failures. To hand a bug to a developer, the call is reproduced by hand again, now with a traffic dump.
  • Waiting for the rig. The test PBX and phones are a shared resource with a queue for it.
  • Missed defects. Attention fades by the three-hundred-fortieth call, and one-way audibility on a single route ships to production.

3. After adoption

Work per month Manual With xk6-sip
Regression, 2400 cases 192 h 54 h: 80% automated, 480 manual cases (38 h) + failure triage at 2 h per run (16 h)
Smoke and features, 840 cases 67 h 34 h: smoke runs by itself, manual work is new features the first time
Writing and maintaining automated tests — 25 h: ~80 new cases × 15 min per test (20 h) + ~5 h updating old tests after PBX changes
Total 259 h, 1.6 FTE 113 h, 0.7 FTE

Hidden costs leave along with the manual dialing. Automated regression takes under an hour of machine time, and a re-run after fixes fits the same day. A failed call arrives with the SIP ladder from trace(), and the test PBX from the repository removes the queue for a rig to debug scenarios. isHeard() does not get tired on the three-hundred-fortieth call.

4. ROI

The saving is about 146 person-hours per month, 56% of call testing, and about 1750 hours per year. At a fully loaded tester hourly cost of 1500 ₽ that is about 220 thousand ₽ per month and 2.6 million ₽ of payroll per year. This is a per-product calculation: with several domains, each with its own branches, manual cost and savings scale with the number of domains. The 80% automation share is a conservative estimate; at 90% the saving grows to about 165 hours per month. The tool itself is free and runs on an ordinary CI runner, so payback comes down to only the first 25 hours of writing scenarios.

Automation takes the routine, not the job

The tool saves the company money, but it does not take the job away from the tester. What gets automated is exactly what nobody enjoys: calling three hundred times, looking at a number, saying "hello", and ticking a box in Xray. The freed 146 hours go where a person is irreplaceable:

  • exploratory testing of new features and hunting for scenarios nobody has thought of yet;
  • checks that do not reduce to yes/no: usability, sound quality by ear, how real phones behave;
  • writing new scenarios and evolving the tool itself;
  • growing into load, metrics, and SIP beyond the "call and take a look" level.

The tester stops being a checklist executor and becomes the author of checks. For the business these are the same people, with more coverage and no burnout from routine.

Under the hood

The engine is written in Go and consists of three layers: SIP transport and transactions from sipgo, a custom dialog layer on top of it, and a custom RTP stack. Below are three engineering decisions that shaped performance, and the benchmark that confirms them.

Why a custom dialog layer

sipgo is a good library for parsing and transactions, but its ready-made dialogs are built for a client with one or two calls. A load test is set up differently: thousands of dialogs live at once, events arrive from both sides in arbitrary order, and the script can hang up at any moment. The first tests under that load found three problems in sipgo:

  • Broken ACK after authentication. When answering 407, the library added credentials straight into the original INVITE. As a result, ACK and CANCEL were built from the modified request and stopped matching the transaction. In the custom implementation the retry goes out as a clone with a new CSeq, and the original is left unchanged.
  • CANCEL bypassing the proxy. The CANCEL went to the host from the Request-URI instead of where the INVITE went. Behind a proxy this means the cancellation is lost and the callee's phone keeps ringing. The custom CANCEL goes to the INVITE's destination address, as RFC 3261 requires.
  • Startup races. The first request could outrun the start of socket reading, and the global read buffer setting was changed from different threads. The race detector caught both cases, not code review.

What remains from sipgo is the parser, transport, and transactions. Everything above that is custom: retries with digest authentication, CANCEL, re-INVITE with 491 handling, PRACK, session timers with 422 and Min-SE, REFER with NOTIFY and Replaces. Along the way the dialog layer became the single point every call message passes through. That is why trace() costs almost nothing, and metrics like sip_invite_delivery_time are computed without watching the network.

Memory math

A subscriber is one UDP socket, one read goroutine, and registration state. By default sipgo allocates a 32 KB read buffer per socket. A SIP message over UDP fits in 4 KB, and that single setting saved about 20 KB per subscriber. The final measurement: about 21 KB per registered subscriber.

Subscribers SIP footprint of the engine
1,000 ~21 MB
10,000 ~210 MB
100,000 ~2.1 GB

The SIP stack itself stops being the bottleneck. What remains is the cost of a VU: each has its own JS runtime, and that is already several megabytes. In practice this means a test of thousands of concurrent calls with media fits on an ordinary CI runner or under Kubernetes. For tens of thousands of subscribers the load spreads across several such pods instead of requiring a dedicated bare-metal server.

One scheduler for all RTP streams

G.711 sends a packet every 20 ms. A thousand streams is 50,000 packets per second, and each must go out on time: a late generator looks in the metrics like PBX jitter.

The naive fix is a time.Ticker and a goroutine per stream. Go timers are cheap and do not create kernel timers, but a thousand independent tickers wake a thousand goroutines at random phases. That is 50,000 wakeups and context switches per second, contention for the scheduler, and wobble in the send timing.

In xk6-sip streams are sharded by core count (GOMAXPROCS). Each shard has one 20 ms ticker that sends packets for all of its active streams in a single pass. Two more decisions on the hot path:

  • Audio is encoded to G.711 once for the whole process. A thousand calls with one WAV read the same buffer.
  • The RTP header is written by hand into a reusable buffer, so sending a packet allocates nothing. The garbage collector does not interfere with send timing.

The receiver counts loss and jitter per RFC 3550 and audibility for isHeard(). RTP is symmetric: packets go to where the media actually comes from, so the test does not break behind NAT.

Benchmark

Linux, 12 cores, 500 VUs, 10-second conversations, about a thousand concurrent RTP streams through the test PBX.

Metric Value Architectural significance
Virtual Users 500, 10 s conversations high density: each VU drives both sides of the call
Successful calls 100%, 2289 calls the dialog layer loses no events under concurrent access
Legs with audible sound 4578 of 4578 media flows both ways on every call
RTP packets received / lost 2,424,988 / 0 the scheduler and allocation-free sending keep up with 50,000 packets per second
Jitter p95 0.9 ms the garbage collector barely affects media send timing
k6 CPU and memory 62% of one core, 222 MB the test fits on a cheap CI instance

The generator does not become a bottleneck and adds almost no jitter of its own, so the metrics show the PBX rather than the rig. One practical rule: precise measurements are taken on Linux. The Go clock on Windows ticks in steps of about 0.5 ms, and anything faster is rounded to zero there.

Quality is held by CI on every push. It runs tests with the race detector, a k6 build with the extension, and xk6 lint with gosec and govulncheck — the same set of checks the Grafana extension registry requires.

Project future

Today xk6-sip answers the question "can the PBX handle load right now". The next goal is for it to answer "did it get better or worse with the new version" without manual comparison of reports. What follows is a plan, not shipped work.

Grafana dashboards for the load client

A ready-made dashboard ships with the extension and comes up with a single command alongside Prometheus. It shows calls per second and concurrent channels, setup time and PDD as percentiles, the successful-call share broken down by response codes, one-way audibility, loss, and jitter. Separately it tracks the health of the generator itself, so it is immediately visible whether the PBX or the rig hit the wall.

Benchmark catalog by PBX version

Every run is tagged with the version of the PBX under test and the load profile:

k6 run --tag pbx_version=4.2.1 --tag profile=caps20 examples/call.js

From these tags Grafana builds a catalog: for each version, the CAPS ceiling, p95 setup time, and one-way audibility share on a reference profile. Releases are marked with annotations, and a degradation shows on the chart as a step rather than as a tester's feeling.

Automatic degradation detection

A reference run of the previous version becomes the baseline. CI runs the same profile against the new PBX build and compares: if p95 setup time grows by more than 10% or the successful-call share drops, the build fails. Performance regressions are caught before release — the same way functional ones are, with the same report in Xray.

Finding the ceiling and capacity planning

A stepped ramping-arrival-rate to failure with automatic stop on thresholds (abortOnFail) produces the key operations number: how many calls per second and concurrent channels a given PBX configuration sustains on given hardware. Capacity planning rests on measurement rather than on the vendor's datasheet.

Long-running and stress scenarios

  • 24-hour soak tests: memory leaks in the PBX, accumulation of stuck dialogs, re-registration storms.
  • A bad network: loss, delay, and jitter via tc netem between the generator and the PBX.
  • Cluster node failures under load: how many calls are lost during failover and how long the system takes to recover.

Synthetic call monitoring

The same functional scenario, run on a schedule against the production PBX, becomes synthetic monitoring. Every five minutes a robot calls itself through the PBX, checks caller ID, audio, and DTMF, and raises an alert if something broke. The problem is learned about before the first customer calls, and the check history becomes the foundation for telephony SLOs.

Scale and voice quality

  • Distributed load through k6-operator in Kubernetes: tens of thousands of subscribers across several generators.
  • RTCP reports and MOS scoring with the E-model alongside loss and jitter.
  • SIP over TCP and TLS, SRTP, G.722 and Opus codecs.
  • Recording comparison against a reference: verify the IVR played exactly that greeting.

The API reference is already formatted in k6 documentation style: one page per method, with parameters and examples. If your PBX needs something from the plan earlier than the rest, file an issue — I will order the roadmap by them.

In place of a conclusion

xk6-sip grew at the intersection of four areas, and none of them could be skipped:

  • SIP and RTP protocols — the heavyweights of the telephony industry.
  • Go and concurrency — thousands of dialogs without races, integration with k6's JS runtime.
  • CI/CD — JUnit, Xray, thresholds as a build acceptance criterion, static analysis and vulnerability checks on every push.
  • Observability — metrics that answer operations questions, and their path into Prometheus and Grafana.

The project keeps moving: dashboards, a benchmark catalog by version, synthetic monitoring. But the core idea is wider than telephony. Any asynchronous system with several participants — message queues, chats, IoT, payment chains — can be described in the same language of actions and expectations. It then gets tested like infrastructure in IaC: code in Git, with review, metrics, and thresholds.