1/** 2 * AI Software Factory — k6: realtime 10k load (TASK-0135, the §26 matrix). 3 * 4 * Scenario (the spec, verbatim): 5 * - connect 10,000 concurrent ws over 5 min (100 rooms × 100 conns, 6 * jittered join), then held 15 min steady state. 7 * - edit all 10k send block-edit (Yjs update) ops, 200 msg/s aggregate 8 * (rate-limited per-conn: 200/10000 = 0.02 msg/s = 1 op/50 s). 9 * - presence 1,000 presence identities, join/leave churn 10/min. 10 * - (verify) a dedicated room the zero-dropped-op hash check targets. 11 * 12 * Thresholds (staging-hard): 13 * op_ack_ms p(95) < 200 ms (client-observed send-update → ack) 14 * connect_ms p(95) < 1 000 ms (connect → hello) 15 * protocol_errors count < 1 (0 FORBIDDEN / bad_update / error) 16 * ws_unexpected_closes rate < 0.005 (disconnects < 0.5%, excl. the 17 * scripted clean close at deadline) 18 * 19 * Zero-dropped Yjs ops (the spec's "final doc state hash equals the 20 * sequential-replay hash") is a HOST-SIDE check, not a k6 threshold — k6's 21 * goja VM has no Yjs to hash a doc. `run-realtime-load.sh` (this dir) does it: 22 * the k6 `verify-writers` scenario sends a fixed, known set of Yjs updates to 23 * the `page:verify` room; the runner connects, full-syncs that room, and asserts 24 * the server's final doc state hashes to the same value as a fresh Yjs doc with 25 * exactly those updates applied in order. A lost update changes the hash. 26 * 27 * Protocol (the real TASK-0091 contract — packages/shared frames.ts), reusing 28 * tests/performance/realtime-1k.js conventions: 29 * connect: <K6_REALTIME_URL>/realtime?token=<jwt>&room=page:<pageId> 30 * header: X-Forwarded-For: 10.100.<a>.<b> (per-VU identity) 31 * hello: {type:"hello", connId, canEdit, serverTime, roomPeers} 32 * update: binary 0x01 + YjsUpdate → server logs it and replies 33 * ack: {type:"ack", seq} (the op-ack — measured here) 34 * awareness: binary 0x02 + JSON state 35 * ping: {type:"ping", ts} → {type:"ping-pong", ts} 36 * 37 * Why 10,000 distinct identities + distinct source IPs (MANDATORY): the 38 * server caps 100 live sockets/user + 10/user control + 30/min connect per 39 * source IP. One shared token can never reach 10,000 (the 101st is 429). Each 40 * VU mints its own HS256 JWT (k6/crypto) for a distinct seeded user id and 41 * presents a per-socket X-Forwarded-For, so the server must run 42 * REALTIME_TRUST_PROXY=1. The identities + EDIT grants come from the seeder 43 * (helpers/seed-1k-load.mjs with K6_USERS=10000 + K6_PAGE_IDS = 100 rooms). 44 * 45 * Env: 46 * K6_REALTIME_URL ws base (default ws://localhost:4001) 47 * K6_JWT_SECRET shared HS256 secret (required — must match the server) 48 * K6_WORKSPACE_ID the loaded workspace (required) 49 * K6_PAGE_IDS 100 room page ids, comma-separated (required) 50 * K6_VUS total edit VUs (default 10000) 51 * K6_RAMP_SECONDS ramp 0→VUS (default 300 = 5 min) 52 * K6_HOLD_SECONDS steady state (default 900 = 15 min) 53 * K6_PRESENCE_VUS presence identities (default 1000) 54 * K6_OP_ACK_P95_MS op-ack p95 budget (default 200) 55 * K6_CONNECT_P95_MS connect p95 budget (default 1000) 56 * K6_STRICT "1" hard (default) / "0" report-only (trivially-true 57 * thresholds; the run still prints real values) 58 * 59 * Run (staging): K6_STRICT=1 … run-realtime-load.sh 60 * Run (smoke): K6_PROFILE=smoke pnpm --dir tests/performance realtime-load 61 */ 62import ws from "k6/ws"; 63import crypto from "k6/crypto"; 64import encoding from "k6/encoding"; 65import { sleep } from "k6"; 66import { Trend, Rate, Counter } from "k6/metrics"; 67 68const OP_ACK_MS = new Trend("op_ack_ms", true); 69const CONNECT_MS = new Trend("connect_ms", true); 70const RTT_MS = new Trend("rtt_ms", true); 71const joinFailures = new Rate("join_failures"); 72const protocolErrors = new Counter("protocol_errors"); 73const unexpectedCloses = new Rate("ws_unexpected_closes"); 74const updateAcks = new Counter("update_acks"); 75const presenceJoins = new Counter("presence_joins"); 76 77const WS_URL = __ENV.K6_REALTIME_URL || "ws://localhost:4001"; 78const SECRET = __ENV.K6_JWT_SECRET || ""; 79const WORKSPACE_ID = __ENV.K6_WORKSPACE_ID || ""; 80const PAGE_IDS = String(__ENV.K6_PAGE_IDS || "").split(",").filter(Boolean); 81const SMOKE = __ENV.K6_PROFILE === "smoke"; 82const STRICT = (__ENV.K6_STRICT || "1") !== "0"; 83 84const VUS = parseInt(__ENV.K6_VUS || (SMOKE ? "100" : "10000"), 10); 85const RAMP_SECONDS = parseInt(__ENV.K6_RAMP_SECONDS || (SMOKE ? "10" : "300"), 10); 86const HOLD_SECONDS = parseInt(__ENV.K6_HOLD_SECONDS || (SMOKE ? "20" : "900"), 10); 87const PRESENCE_VUS = parseInt(__ENV.K6_PRESENCE_VUS || (SMOKE ? "10" : "1000"), 10); 88const OP_ACK_P95_MS = parseInt(__ENV.K6_OP_ACK_P95_MS || "200", 10); 89const CONNECT_P95_MS = parseInt(__ENV.K6_CONNECT_P95_MS || "1000", 10); 90 91// 200 msg/s aggregate across VUS ⇒ per-VU cadence (rate-limited per-conn). 92const MSG_INTERVAL_MS = Math.max(100, Math.round((VUS * 1000) / 200)); 93// Presence churn: 10 join/leave cycles per minute fleet-wide. A small active 94// set of `PRESENCE_ACTIVE` VUs (constant-vus) each runs one join→awareness→ 95// leave cycle per minute; each wave draws PRESENCE_ACTIVE DISTINCT identities 96// (see presenceChurn), advancing the pool by that many — so all PRESENCE_VUS 97// identities are exercised over the run. Smoke caps the pool to 10 (the 98// seeded smoke fixture has 100 load users; the pool only needs a slice). 99const PRESENCE_ACTIVE = 10; 100 101// The zero-dropped-op verify room + its fixed update set (host-replayed). 102// A dedicated seeded page id for the verify room (the server only joins 103// rooms for real pages). The runner/seeder creates it (K6_PAGES includes 104// load-p-verify); overridable. 105const VERIFY_ROOM = __ENV.K6_VERIFY_PAGE_ID || "load-p-verify"; 106// 4 valid Yjs updates (base64) — each inserts a distinct marker into the Y.Text 107// "k6". Re-applying is an idempotent no-op, but a LOST one is not: the final 108// doc is missing that insert, so its state hash differs from the replay. 109// (Same regenerated set as realtime-1k.js — see its header note.) 110const YJS_UPDATES = [ 111 "AQGCprqECwAEAQJrNhlrNiBsb2FkIHVwZGF0ZSB2YXJpYW50IDAgAA==", 112 "AQHgk6jsCgAEAQJrNhlrNiBsb2FkIHVwZGF0ZSB2YXJpYW50IDEgAA==", 113 "AQGf2MjCBAAEAQJrNhlrNiBsb2FkIHVwZGF0ZSB2YXJpYW50IDIgAA==", 114 "AQHzmP0+AAQBAms2GWs2IGxvYWQgdXBkYXRlIHZhcmlhbnQgMyAA", 115]; 116const BINARY_KIND = { Update: 0x01, Awareness: 0x02 }; 117 118/** base64url for JWT segments (no padding). */ 119function b64url(input) { 120 return input.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 121} 122 123/** 124 * Mint an access JWT (HS256, shared secret) — the exact claims shape 125 * @factory/auth/tokens + apps/realtime/src/auth.ts accept: sub, workspaces[], 126 * iat, exp (unix seconds), jti. (Reused from realtime-1k.js.) 127 */ 128function mintToken(sub, workspaces) { 129 const nowSec = Math.floor(Date.now() / 1000); 130 const header = b64url(encoding.b64encode(JSON.stringify({ alg: "HS256", typ: "JWT" }), "base64")); 131 const claims = b64url( 132 encoding.b64encode( 133 JSON.stringify({ 134 sub: sub, 135 workspaces: workspaces, 136 iat: nowSec, 137 exp: nowSec + 900, 138 jti: `k6-${sub}-${nowSec}`, 139 }), 140 "base64", 141 ), 142 ); 143 const sig = b64url(crypto.hmac("sha256", SECRET, `${header}.${claims}`, "base64")); 144 return `${header}.${claims}.${sig}`; 145} 146 147/** binary 0x01 + a valid Yjs update → ArrayBuffer for ws.sendBinary. */ 148function updateFrame(variant) { 149 const bytes = encoding.b64decode(YJS_UPDATES[variant % YJS_UPDATES.length], "base64"); 150 const out = new Uint8Array(bytes.length + 1); 151 out[0] = BINARY_KIND.Update; 152 out.set(bytes, 1); 153 return out.buffer; 154} 155 156/** binary 0x02 + a small awareness state. */ 157function awarenessFrame(userId) { 158 const payload = JSON.stringify({ 159 user: { id: userId, name: "k6", color: "slate" }, 160 cursor: { x: (userId.length * 7) % 800, y: (userId.length * 13) % 600 }, 161 }); 162 const data = encoding.b64encode(payload, "utf8"); 163 const out = new Uint8Array(data.length + 1); 164 out[0] = BINARY_KIND.Awareness; 165 out.set(data, 1); 166 return out.buffer; 167} 168 169export function setup() { 170 if (!SECRET) throw new Error("setup: K6_JWT_SECRET is required (the shared HS256 secret)"); 171 if (!WORKSPACE_ID) throw new Error("setup: K6_WORKSPACE_ID is required (the seeder prints it)"); 172 if (PAGE_IDS.length === 0) throw new Error("setup: K6_PAGE_IDS is required (100 room page ids)"); 173 if (!SMOKE && PAGE_IDS.length < 100) { 174 throw new Error(`setup: full profile needs 100 room page ids, got ${PAGE_IDS.length}`); 175 } 176 return { ok: true }; 177} 178 179/** 180 * The 10k edit scenario: connect (jittered), hold, send Yjs update ops at the 181 * per-conn rate-limited cadence, clean close. Measures connect_ms + op_ack_ms. 182 */ 183export function editLoad() { 184 const vu = __VU; 185 // 4-digit ids — the seeder (helpers/seed-1k-load.mjs) creates 186 // k6-load-u-0000 … k6-load-u-9999 (10,000 fit exactly in 4 digits). 187 const pad4 = String(vu - 1).padStart(4, "0"); 188 const userId = `k6-load-u-${pad4}`; 189 const ip = `10.100.${Math.floor((vu - 1) / 256)}.${(vu - 1) % 256}`; 190 // 100 rooms × 100 conns: VU i → room (i-1) mod 100. 191 const roomIdx = (vu - 1) % Math.max(1, Math.min(100, PAGE_IDS.length)); 192 const pageId = PAGE_IDS[roomIdx]; 193 const token = mintToken(userId, [WORKSPACE_ID]); 194 195 // Real thundering-herd de-correlation: a per-VU 0..~1.05 s pre-DELAY (an 196 // actual sleep, not a timestamp offset) so the 100 conns of a room don't all 197 // hit the upgrade in the same instant. The ramp already spreads joins; this 198 // adds room-level de-correlation. connectStart is the TRUE connect time 199 // (after the delay) so CONNECT_MS (connect→hello) is a real, non-negative 200 // latency. 201 sleep((vu % 7) * 0.15); 202 const connectStart = Date.now(); 203 204 const url = `${WS_URL}/realtime?token=${encodeURIComponent(token)}&room=${encodeURIComponent(`page:${pageId}`)}`; 205 const holdMs = Math.max(1000, HOLD_SECONDS * 1000); 206 let joined = false; 207 let lastSentTs = 0; // for op-ack (one outstanding update per VU at this rate) 208 const sentTs = {}; 209 210 // NOTE: ws.connect's return value is NOT used for success detection. On 211 // k6 v0.57 the returned object has no reliable `.Status` (measured 212 // `undefined` on a successful join, and property access on the native 213 // value throws in goja) — so `resp.Status !== 101` would false-positive 214 // (or throw) on every HEALTHY join. The only robust success signal is the 215 // server's `hello` frame, which it sends only after the full auth + room + 216 // permission chain resolves. `joined` (set in the on-message handler below) 217 // is therefore the join-success oracle; `!joined` after the hold = a join 218 // failure (covers 401/403/400/429 upgrade rejects AND the 500-permission 219 // path, which upgrades OK but never sends hello). 220 ws.connect( 221 url, 222 { headers: { "x-forwarded-for": ip } }, 223 (socket) => { 224 socket.on("message", (raw) => { 225 let msg; 226 try { 227 msg = JSON.parse(raw); 228 } catch { 229 return; 230 } 231 switch (msg.type) { 232 case "hello": 233 joined = true; 234 CONNECT_MS.add(Date.now() - connectStart); // connect → hello 235 if (msg.canEdit !== true) protocolErrors.add(1); // VIEW hello = wiring bug 236 break; 237 case "ack": 238 // op-ack: the client-observed latency of a sent update. 239 updateAcks.add(1); 240 if (lastSentTs) OP_ACK_MS.add(Date.now() - lastSentTs); 241 break; 242 case "ping-pong": 243 if (typeof msg.ts === "number" && sentTs[msg.ts]) { 244 RTT_MS.add(Date.now() - msg.ts); 245 delete sentTs[msg.ts]; 246 } 247 break; 248 case "error": 249 protocolErrors.add(1); 250 break; 251 // sync.state / doc.persisted / … — ignore. 252 } 253 }); 254 socket.on("binaryMessage", () => {}); // relayed updates/awareness 255 socket.on("close", () => { 256 // "Unexpected" = we HAD a good join (hello arrived) and the socket 257 // dropped before the hold window. A failed join (rejected upgrade, or 258 // the 500-permission path — no hello) is a join_failure, not an 259 // unexpected close, so gate on `joined`. 260 if (joined && Date.now() < connectStart + holdMs - 5000) unexpectedCloses.add(1); 261 }); 262 socket.on("error", () => protocolErrors.add(1)); 263 264 // The rate-limited 200 msg/s aggregate: one op per MSG_INTERVAL_MS, 265 // alternating update / awareness / ping (the block-edit op is the update). 266 let tick = 0; 267 socket.setInterval(() => { 268 const r = Math.random(); 269 if (r < 0.6) { 270 lastSentTs = Date.now(); 271 socket.sendBinary(updateFrame(tick)); 272 } else if (r < 0.8) { 273 socket.sendBinary(awarenessFrame(userId)); 274 } else { 275 const ts = Date.now(); 276 sentTs[ts] = true; 277 socket.send(JSON.stringify({ type: "ping", ts: ts })); 278 } 279 tick += 1; 280 }, MSG_INTERVAL_MS); 281 282 socket.setTimeout(() => { 283 if (joined) socket.close(1000); // scripted clean close (not a disconnect) 284 }, holdMs); 285 }, 286 ); 287 288 // HOLD the VU. k6 v0.57's ws.connect is NON-BLOCKING: it returns as soon as 289 // the upgrade is initiated, so without an explicit hold the iteration 290 // returns instantly and k6 fires the next one — a tight connect-retry loop 291 // (measured: ~1,068 iters/s, 42k joins in a 30 s smoke). The socket's 292 // setInterval (edit cadence) + setTimeout (scripted close at holdMs) keep 293 // the connection alive and drive the metrics; each 1 s sleep YIELDS to the 294 // socket event loop (k6 timers need the VU to yield), so the edit cadence 295 // keeps ticking. The socket's own close ends this iteration (k6 returns a 296 // VU to the pool when its socket closes). 297 const editDeadline = Date.now() + holdMs + 5_000; // grace past the scripted close 298 while (Date.now() < editDeadline) sleep(1); 299 300 // Join-failure detection — the `hello` oracle (see the note above ws.connect). 301 // No hello by the end of the hold = the join never established (bad token / 302 // 403 / 429 / 400, or the 500-permission path that upgrades but never 303 // sends hello). On a healthy post-fix server this stays ~0; on the current 304 // buggy server it is ~1.0 (the dev report prints it without blocking). 305 if (!joined) joinFailures.add(1); 306} 307 308/** 309 * Presence churn: 10 active VUs, each a join→awareness→leave cycle once a 310 * minute (10/min fleet), round-robining the PRESENCE_VUS identity pool. 311 */ 312export function presenceChurn() { 313 const cycle = __ITER + 1; 314 // 10 active VUs × one cycle per minute = 10 join/leave cycles per minute 315 // fleet-wide. The `+ (__VU - 1)` offset makes each wave draw 10 DISTINCT 316 // identities (constant-vus gives every VU its own __ITER, so without the 317 // offset all 10 VUs of a wave would reuse the same identity); each wave 318 // advances the pool by PRESENCE_ACTIVE, so all PRESENCE_VUS identities are 319 // exercised over the run. 320 const userIdx = ((cycle - 1) * PRESENCE_ACTIVE + (__VU - 1)) % PRESENCE_VUS; 321 // Presence identities reuse the seeded load-user pool (k6-load-u-XXXX — 322 // they carry the workspace EDIT grant; a k6-pres-u-* id would have no user 323 // row and 403 the join). 324 const pad4 = String(userIdx).padStart(4, "0"); 325 const userId = `k6-load-u-${pad4}`; 326 const ip = `10.100.${200 + Math.floor(userIdx / 256)}.${userIdx % 256}`; 327 const pageId = PAGE_IDS[userIdx % PAGE_IDS.length]; 328 const token = mintToken(userId, [WORKSPACE_ID]); 329 330 const url = `${WS_URL}/realtime?token=${encodeURIComponent(token)}&room=${encodeURIComponent(`page:${pageId}`)}`; 331 // joined is the join-success oracle (hello frame) — see the note in 332 // editLoad. Presence joins are short; a join that never hellos within the 333 // 5 s dwell is a join_failure. 334 let joined = false; 335 ws.connect( 336 url, 337 { headers: { "x-forwarded-for": ip } }, 338 (socket) => { 339 socket.on("message", (raw) => { 340 let msg; 341 try { 342 msg = JSON.parse(raw); 343 } catch { 344 return; 345 } 346 if (msg.type === "hello") { 347 joined = true; 348 presenceJoins.add(1); 349 socket.sendBinary(awarenessFrame(userId)); 350 } else if (msg.type === "error") { 351 protocolErrors.add(1); 352 } 353 }); 354 socket.on("binaryMessage", () => {}); 355 // Short presence dwell (5 s) then leave — the join/leave churn. 356 socket.setTimeout(() => { 357 if (joined) socket.close(1000); 358 }, 5000); 359 }, 360 ); 361 362 // HOLD the VU (ws.connect is non-blocking on k6 v0.57 — same as editLoad): 363 // hold ~7 s past the scripted 5 s presence dwell, then let the next cycle 364 // draw a fresh identity. 365 const presenceDeadline = Date.now() + 7_000; 366 while (Date.now() < presenceDeadline) sleep(1); 367 if (!joined) joinFailures.add(1); 368} 369 370/** 371 * The zero-dropped-op verify writers: a small set of VUs in the dedicated 372 * `page:verify` room, each sending the 4 known Yjs updates exactly once. The 373 * host runner (run-realtime-load.sh) full-syncs this room and asserts its doc 374 * state hash == a fresh doc replayed with these 4 updates (loss ⇒ mismatch). 375 */ 376export function verifyWriters() { 377 // Reuse the first seeded load users (k6-load-u-0000… — they exist in both 378 // the 100-user smoke seed and the 10,000-user full seed and carry the 379 // workspace EDIT grant). 380 const userId = `k6-load-u-${String(__VU - 1).padStart(4, "0")}`; 381 const ip = `10.100.250.${__VU}`; 382 const token = mintToken(userId, [WORKSPACE_ID]); 383 const url = `${WS_URL}/realtime?token=${encodeURIComponent(token)}&room=${encodeURIComponent(`page:${VERIFY_ROOM}`)}`; 384 // joined is the join-success oracle (hello frame) — see the note in 385 // editLoad. A verify writer that never hellos sends no updates (the update 386 // send is gated on `joined`), so the host-side hash check would already 387 // catch the loss; this metric records the join failure explicitly. 388 let joined = false; 389 ws.connect( 390 url, 391 { headers: { "x-forwarded-for": ip } }, 392 (socket) => { 393 socket.on("message", (raw) => { 394 let msg; 395 try { 396 msg = JSON.parse(raw); 397 } catch { 398 return; 399 } 400 if (msg.type === "hello") joined = true; 401 else if (msg.type === "error") protocolErrors.add(1); 402 }); 403 socket.on("binaryMessage", () => {}); 404 socket.on("close", () => {}); 405 // Send all 4 verify updates once the room is joined, then hold. 406 socket.setTimeout(() => { 407 if (!joined) return; 408 for (let v = 0; v < YJS_UPDATES.length; v++) { 409 socket.sendBinary(updateFrame(v)); 410 } 411 }, 2000); 412 socket.setTimeout(() => { 413 if (joined) socket.close(1000); 414 }, Math.max(4000, HOLD_SECONDS * 1000)); 415 }, 416 ); 417 418 // HOLD the VU (ws.connect is non-blocking on k6 v0.57): hold through the 419 // scripted close (max(4 s, HOLD) + the 2 s send delay) so this VU's verify 420 // updates are actually delivered before the iteration ends. 421 const verifyDeadline = Date.now() + Math.max(4000, HOLD_SECONDS * 1000) + 5_000; 422 while (Date.now() < verifyDeadline) sleep(1); 423 if (!joined) joinFailures.add(1); 424} 425 426const HARD_THRESHOLDS = { 427 op_ack_ms: [`p(95)<${OP_ACK_P95_MS}`], 428 connect_ms: [`p(95)<${CONNECT_P95_MS}`], 429 // A load run that establishes ZERO connections has measured nothing — the 430 // op-ack / connect Trends stay empty (avg 0) and would otherwise pass the 431 // p95 budgets vacuously while 100% of handshakes fail. Gate on the 432 // establishment rate so the hard tier actually requires healthy joins. 433 // (Soft is `rate<=1` = always-pass, so report-only runs stay green.) 434 join_failures: ["rate<0.001"], 435 protocol_errors: ["count<1"], 436 ws_unexpected_closes: ["rate<0.005"], 437}; 438// Report-only (K6_STRICT=0): thresholds that can NEVER fail, so the dev/main 439// tier always stays green while the summary still prints the real values. 440// NOTE: k6 Rate thresholds are a FRACTION (0..1), so `rate<=1` is the true 441// "always-pass" bound (a 100% failure rate is exactly 1.0, which would fail 442// `rate<1` — that red-on-soft bug is what the `<=` fixes). 443const SOFT_THRESHOLDS = { 444 op_ack_ms: ["avg<1e15"], 445 connect_ms: ["avg<1e15"], 446 join_failures: ["rate<=1"], 447 protocol_errors: ["count<1e15"], 448 ws_unexpected_closes: ["rate<=1"], 449}; 450 451export const options = { 452 scenarios: { 453 // 10k concurrent over RAMP (5 min), held HOLD (15 min) steady. 454 edit: { 455 executor: "ramping-vus", 456 startVUs: 0, 457 stages: [ 458 { duration: `${RAMP_SECONDS}s`, target: VUS }, 459 { duration: `${HOLD_SECONDS}s`, target: VUS }, 460 ], 461 gracefulStop: "10s", 462 exec: "editLoad", 463 }, 464 // Presence churn 10/min (PRESENCE_ACTIVE VUs × 1 cycle/min). 465 presence: { 466 executor: "constant-vus", 467 startTime: "0s", 468 duration: `${RAMP_SECONDS + HOLD_SECONDS}s`, 469 vus: PRESENCE_ACTIVE, 470 exec: "presenceChurn", 471 }, 472 // Zero-dropped-op verify writers (dedicated room). 473 verify: { 474 executor: "constant-vus", 475 startTime: "0s", 476 duration: `${RAMP_SECONDS + HOLD_SECONDS + 10}s`, 477 vus: SMOKE ? 1 : 4, 478 exec: "verifyWriters", 479 }, 480 }, 481 thresholds: STRICT ? HARD_THRESHOLDS : SOFT_THRESHOLDS, 482 summaryTrendStats: ["avg", "min", "med", "p(90)", "p(95)", "p(99)", "max"], 483}; 484