// Concurrent ServiceOps users replaying what the browser sends: the login form // with its CSRF token, each page's HTML plus (once, as a cached browser would) // its CSS/JS/images, the 25-second background notification poll, and form // posts that create incidents and add work notes. THINK_MIN/THINK_MAX set the // pause between page actions (seconds). import http from "k6/http"; import { check, sleep } from "k6"; import { Trend, Rate, Counter } from "k6/metrics"; import exec from "k6/execution"; const BASE = __ENV.BASE_URL || "http://perf.perftest.svc.cluster.local"; const PASSWORD = __ENV.LOADTEST_PASSWORD; const USERS = parseInt(__ENV.USERS || "500", 10); const THINK_MIN = parseFloat(__ENV.THINK_MIN || "10"); const THINK_MAX = parseFloat(__ENV.THINK_MAX || "20"); const POLL_SECONDS = 25; const RAMP_SECONDS = (() => { const m = /^(\d+)(s|m)$/.exec(__ENV.RAMP || "3m"); return m ? Number(m[1]) * (m[2] === "m" ? 60 : 1) : 180; })(); function phase() { return (Date.now() - exec.scenario.startTime) / 1000 > RAMP_SECONDS + 30 ? "hold" : "ramp"; } const pageTime = new Trend("page_time", true); const failures = new Rate("page_failures"); const actions = new Counter("user_actions"); export const options = { discardResponseBodies: false, scenarios: { users: { executor: "ramping-vus", startVUs: 0, stages: [ { duration: __ENV.RAMP || "3m", target: USERS }, { duration: __ENV.HOLD || "5m", target: USERS }, ], gracefulRampDown: "30s", }, }, summaryTrendStats: ["avg", "min", "med", "p(90)", "p(95)", "p(99)", "max"], thresholds: { "page_time{phase:hold}": ["p(95)<600000"], "page_failures{phase:hold}": ["rate<1"], "http_reqs{phase:hold}": ["count>=0"], "page_time{name:GET / (dashboard)}": ["p(95)<600000"], "page_time{name:GET /tickets/incident}": ["p(95)<600000"], "page_time{name:GET /ticket/}": ["p(95)<600000"], "page_time{name:GET /task-board}": ["p(95)<600000"], "page_time{name:GET /ui/search}": ["p(95)<600000"], "page_time{name:GET /cmdb}": ["p(95)<600000"], "page_time{name:POST /tickets/new/incident}": ["p(95)<600000"], "page_time{name:POST /ticket/ comment}": ["p(95)<600000"], "page_time{name:GET /notifications/poll}": ["p(95)<600000"], "page_time{name:POST /login}": ["p(95)<600000"], "page_time{name:GET /login}": ["p(95)<600000"] }, }; // Distinct documentation-range client addresses (RFC 5737), so per-IP login // limits see separate users as a real audience would. function clientIp(vu) { const ranges = ["198.51.100", "203.0.113", "192.0.2"]; return `${ranges[vu % 3]}.${1 + (Math.floor(vu / 3) % 254)}`; } const state = {}; function session() { const vu = __VU; if (!state[vu]) state[vu] = { cookies: {}, loggedIn: false, assetsLoaded: false, groupId: null, ticketIds: [], lastPoll: 0 }; return state[vu]; } function storeCookies(s, res) { for (const [name, values] of Object.entries(res.cookies || {})) { if (values.length) s.cookies[name] = values[0].value; } } function headers(s, extra = {}) { const cookie = Object.entries(s.cookies).map(([k, v]) => `${k}=${v}`).join("; "); return Object.assign({ "X-Forwarded-For": clientIp(__VU), "X-Forwarded-Proto": "https", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15 ServiceOpsLoadTest", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Cookie": cookie, }, extra); } function get(s, path, name) { const ph = phase(); const res = http.get(`${BASE}${path}`, { headers: headers(s), redirects: 0, tags: { name, phase: ph } }); storeCookies(s, res); pageTime.add(res.timings.duration, { name, phase: ph }); failures.add(res.status >= 400 || res.status === 0, { name, phase: ph }); return res; } function post(s, path, fields, name) { const res = http.post(`${BASE}${path}`, fields, { headers: headers(s, { "Content-Type": "application/x-www-form-urlencoded", "Origin": BASE }), redirects: 0, tags: { name, phase: phase() }, }); storeCookies(s, res); pageTime.add(res.timings.duration, { name, phase: phase() }); failures.add(res.status >= 400 || res.status === 0, { name, phase: phase() }); return res; } function csrf(html) { const m = /name="_csrf_token" value="([^"]+)"/.exec(html || ""); return m ? m[1] : ""; } function loadAssets(s, html) { if (s.assetsLoaded) return; const seen = new Set(); const re = /(?:href|src)="(\/static\/[^"]+)"/g; let m; while ((m = re.exec(html || ""))) seen.add(m[1]); const requests = [...seen].map((path) => ["GET", `${BASE}${path}`, null, { headers: headers(s, { Accept: "*/*" }), tags: { name: "static asset" } }]); if (requests.length) http.batch(requests); s.assetsLoaded = true; } function login(s) { const page = get(s, "/login", "GET /login"); const user = `loadtest-${String(((__VU - 1) % USERS) + 1).padStart(4, "0")}`; const res = post(s, "/login", { username: user, password: PASSWORD, provider: "local", _csrf_token: csrf(page.body), }, "POST /login"); const ok = check(res, { "logged in": (r) => r.status === 302 && !(r.headers.Location || "").includes("/login") }); s.lastPoll = Date.now() - Math.random() * POLL_SECONDS * 1000; if (ok) { const home = get(s, "/", "GET / (dashboard)"); loadAssets(s, home.body); } return ok; } function pickTicket(s) { if (!s.ticketIds.length) { const list = get(s, "/tickets/incident", "GET /tickets/incident"); const re = /href="\/ticket\/(\d+)"/g; let m; while ((m = re.exec(list.body || ""))) s.ticketIds.push(m[1]); } return s.ticketIds.length ? s.ticketIds[Math.floor(Math.random() * s.ticketIds.length)] : null; } function createIncident(s) { const form = get(s, "/tickets/new/incident", "GET /tickets/new/incident"); if (!s.groupId) { const m = /name="group_id"[^>]*>[\s\S]*?]*value="(\d+)"/.exec(form.body || ""); s.groupId = m ? m[1] : null; } if (!s.groupId) return; const res = post(s, "/tickets/new/incident", { _csrf_token: csrf(form.body), title: `Load test incident from VU ${__VU}`, description: "Synthetic incident raised during the load test.", group_id: s.groupId, impact: "Medium", urgency: "Medium", contact_type: "Self-service", }, "POST /tickets/new/incident"); check(res, { "incident created": (r) => r.status === 302 }); } function addWorkNote(s) { const id = pickTicket(s); if (!id) return; const page = get(s, `/ticket/${id}`, "GET /ticket/"); const res = post(s, `/ticket/${id}`, { _csrf_token: csrf(page.body), action: "comment", body: `Load test work note from VU ${__VU}`, }, "POST /ticket/ comment"); check(res, { "work note saved": (r) => r.status === 302 }); } const ACTIONS = [ [30, (s) => get(s, "/", "GET / (dashboard)")], [20, (s) => get(s, "/tickets/incident", "GET /tickets/incident")], [15, (s) => { const id = pickTicket(s); if (id) get(s, `/ticket/${id}`, "GET /ticket/"); }], [10, (s) => get(s, "/task-board", "GET /task-board")], [8, (s) => get(s, "/ui/search?q=load", "GET /ui/search")], [7, (s) => get(s, "/cmdb", "GET /cmdb")], [5, createIncident], [5, addWorkNote], ]; const TOTAL = ACTIONS.reduce((sum, [w]) => sum + w, 0); export default function () { const s = session(); if (!s.loggedIn) { s.loggedIn = login(s); if (!s.loggedIn) { sleep(5); return; } } let roll = Math.random() * TOTAL; for (const [weight, action] of ACTIONS) { roll -= weight; if (roll <= 0) { action(s); break; } } actions.add(1); // The browser polls on a 25-second timer, independent of clicks. const pause = THINK_MIN + Math.random() * (THINK_MAX - THINK_MIN); let waited = 0; while (waited < pause) { const untilPoll = Math.max(0, POLL_SECONDS - (Date.now() - s.lastPoll) / 1000); if (untilPoll <= 0) { get(s, "/notifications/poll", "GET /notifications/poll"); s.lastPoll = Date.now(); continue; } const step = Math.min(untilPoll, pause - waited); sleep(step); waited += step; } }