From 31f8274b8d55f6541651904285fdc592b5fd4242 Mon Sep 17 00:00:00 2001 From: JoeBot Date: Thu, 19 Mar 2026 16:11:32 -0400 Subject: [PATCH] Upload files to "load-tests" load test files --- load-tests/auth-dashboard-flow.js | 183 +++++++++++++++++++++ load-tests/baseline.json | 45 ++++++ load-tests/crud-flow.js | 259 ++++++++++++++++++++++++++++++ load-tests/cycle-template.md | 117 ++++++++++++++ load-tests/environments.json | 38 +++++ 5 files changed, 642 insertions(+) create mode 100644 load-tests/auth-dashboard-flow.js create mode 100644 load-tests/baseline.json create mode 100644 load-tests/crud-flow.js create mode 100644 load-tests/cycle-template.md create mode 100644 load-tests/environments.json diff --git a/load-tests/auth-dashboard-flow.js b/load-tests/auth-dashboard-flow.js new file mode 100644 index 0000000..aaf5a90 --- /dev/null +++ b/load-tests/auth-dashboard-flow.js @@ -0,0 +1,183 @@ +/** + * HOALedgerIQ – Auth + Dashboard Load Test + * Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout + * + * Covers the highest-frequency production flow: a treasurer or admin + * opening the app, loading the dashboard, and reviewing financial reports. + */ + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Trend, Rate, Counter } from 'k6/metrics'; + +// ── Custom metrics ────────────────────────────────────────────────────────── +const loginDuration = new Trend('login_duration', true); +const dashboardDuration = new Trend('dashboard_duration', true); +const refreshDuration = new Trend('refresh_duration', true); +const authErrorRate = new Rate('auth_error_rate'); +const dashboardErrorRate = new Rate('dashboard_error_rate'); +const tokenRefreshCount = new Counter('token_refresh_count'); + +// ── User pool ──────────────────────────────────────────────────────────────── +const users = new SharedArray('users', function () { + return open('../config/user-pool.csv') + .split('\n') + .slice(1) // skip header row + .filter(line => line.trim()) + .map(line => { + const [email, password, orgId, role] = line.split(','); + return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; + }); +}); + +// ── Environment config ─────────────────────────────────────────────────────── +const ENV = __ENV.TARGET_ENV || 'staging'; +const envConfig = JSON.parse(open('../config/environments.json'))[ENV]; +const BASE_URL = envConfig.baseUrl; + +// ── Test options ───────────────────────────────────────────────────────────── +export const options = { + scenarios: { + auth_dashboard: { + executor: 'ramping-vus', + stages: [ + { duration: '2m', target: 20 }, // warm up + { duration: '5m', target: 100 }, // ramp to target load + { duration: '5m', target: 100 }, // sustained load + { duration: '3m', target: 200 }, // peak spike + { duration: '2m', target: 0 }, // ramp down + ], + }, + }, + thresholds: { + // Latency targets per environment (overridden by environments.json) + 'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`], + 'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`], + 'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`], + 'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`], + 'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`], + 'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`], + 'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`], + }, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── +function authHeaders(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} + +// ── Main scenario ──────────────────────────────────────────────────────────── +export default function () { + const user = users[__VU % users.length]; + let accessToken = null; + + // ── 1. Login ──────────────────────────────────────────────────────────── + group('auth:login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } } + ); + + loginDuration.add(res.timings.duration); + const ok = check(res, { + 'login 200': r => r.status === 200, + 'has access_token': r => r.json('access_token') !== undefined, + 'has orgId in body': r => r.json('user.orgId') !== undefined, + }); + authErrorRate.add(!ok); + if (!ok) { sleep(1); return; } + + accessToken = res.json('access_token'); + // httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar + }); + + if (!accessToken) return; + sleep(1.5); // think time – user lands on dashboard + + // ── 2. Load dashboard & key reports in parallel ───────────────────────── + group('dashboard:load', () => { + const requests = { + dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`], + balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`], + income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`], + profile: ['GET', `${BASE_URL}/api/auth/profile`], + accounts: ['GET', `${BASE_URL}/api/accounts`], + }; + + const responses = http.batch( + Object.entries(requests).map(([name, [method, url]]) => ({ + method, url, + params: { headers: authHeaders(accessToken), tags: { name } }, + })) + ); + + let allOk = true; + responses.forEach((res, i) => { + const name = Object.keys(requests)[i]; + dashboardDuration.add(res.timings.duration, { endpoint: name }); + const ok = check(res, { + [`${name} 200`]: r => r.status === 200, + [`${name} has body`]: r => r.body && r.body.length > 0, + }); + if (!ok) allOk = false; + }); + dashboardErrorRate.add(!allOk); + }); + + sleep(2); // user reads the dashboard + + // ── 3. Simulate token refresh (happens automatically in-app at 55min) ──── + // In the load test we trigger it early to validate the refresh path under load + group('auth:refresh', () => { + const res = http.post( + `${BASE_URL}/api/auth/refresh`, + null, + { + headers: authHeaders(accessToken), + tags: { name: 'refresh' }, + // k6 sends the httpOnly cookie from the jar automatically + } + ); + + refreshDuration.add(res.timings.duration); + tokenRefreshCount.add(1); + const ok = check(res, { + 'refresh 200': r => r.status === 200, + 'new access_token': r => r.json('access_token') !== undefined, + }); + authErrorRate.add(!ok); + if (ok) accessToken = res.json('access_token'); + }); + + sleep(1); + + // ── 4. Drill into one report (cash-flow forecast – typically slowest) ──── + group('dashboard:drill', () => { + const res = http.get( + `${BASE_URL}/api/reports/cash-flow-forecast`, + { headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } } + ); + dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' }); + dashboardErrorRate.add(res.status !== 200); + check(res, { 'forecast 200': r => r.status === 200 }); + }); + + sleep(2); + + // ── 5. Logout ──────────────────────────────────────────────────────────── + group('auth:logout', () => { + const res = http.post( + `${BASE_URL}/api/auth/logout`, + null, + { headers: authHeaders(accessToken), tags: { name: 'logout' } } + ); + check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 }); + }); + + sleep(1); +} diff --git a/load-tests/baseline.json b/load-tests/baseline.json new file mode 100644 index 0000000..4fb7065 --- /dev/null +++ b/load-tests/baseline.json @@ -0,0 +1,45 @@ +{ + "_meta": { + "description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.", + "last_updated": "YYYY-MM-DD", + "last_run_cycle": 0, + "units": "milliseconds" + }, + "auth": { + "POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "reports": { + "GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "accounts": { + "GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "journal_entries": { + "GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "budgets": { + "GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "invoices": { + "GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null } + }, + "payments": { + "GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }, + "POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null } + } +} diff --git a/load-tests/crud-flow.js b/load-tests/crud-flow.js new file mode 100644 index 0000000..affb72c --- /dev/null +++ b/load-tests/crud-flow.js @@ -0,0 +1,259 @@ +/** + * HOALedgerIQ – Core CRUD Workflow Load Test + * Journey: Login → Create Journal Entry → Post It → Create Invoice → + * Record Payment → View Accounts → Budget vs Actual → Logout + * + * This scenario exercises write-heavy paths gated by WriteAccessGuard + * and the TenantMiddleware schema-switch. Run this alongside + * auth-dashboard-flow.js to simulate a realistic mixed workload. + * + * Role used: treasurer (has full write access, most common power user) + */ + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Trend, Rate } from 'k6/metrics'; +import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; + +// ── Custom metrics ────────────────────────────────────────────────────────── +const journalEntryDuration = new Trend('journal_entry_duration', true); +const invoiceDuration = new Trend('invoice_duration', true); +const paymentDuration = new Trend('payment_duration', true); +const accountsReadDuration = new Trend('accounts_read_duration', true); +const budgetDuration = new Trend('budget_vs_actual_duration',true); +const crudErrorRate = new Rate('crud_error_rate'); +const writeGuardErrorRate = new Rate('write_guard_error_rate'); + +// ── User pool (treasurer + admin roles only for write access) ──────────────── +const users = new SharedArray('users', function () { + return open('../config/user-pool.csv') + .split('\n') + .slice(1) + .filter(line => line.trim()) + .map(line => { + const [email, password, orgId, role] = line.split(','); + return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; + }) + .filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role)); +}); + +// ── Environment config ─────────────────────────────────────────────────────── +const ENV = __ENV.TARGET_ENV || 'staging'; +const envConfig = JSON.parse(open('../config/environments.json'))[ENV]; +const BASE_URL = envConfig.baseUrl; + +// ── Test options ───────────────────────────────────────────────────────────── +export const options = { + scenarios: { + crud_workflow: { + executor: 'ramping-vus', + stages: [ + { duration: '2m', target: 10 }, // warm up (writes need more care) + { duration: '5m', target: 50 }, // ramp to target + { duration: '5m', target: 50 }, // sustained + { duration: '3m', target: 100 }, // peak + { duration: '2m', target: 0 }, // ramp down + ], + }, + }, + thresholds: { + 'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`], + 'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`], + 'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`], + 'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`], + 'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`], + 'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`], + 'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero + 'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`], + 'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`], + }, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} + +function currentYear() { + return new Date().getFullYear(); +} + +// ── Main scenario ──────────────────────────────────────────────────────────── +export default function () { + const user = users[__VU % users.length]; + let accessToken = null; + + // ── 1. Login ──────────────────────────────────────────────────────────── + group('auth:login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } } + ); + const ok = check(res, { + 'login 200': r => r.status === 200, + 'has access_token': r => r.json('access_token') !== undefined, + }); + crudErrorRate.add(!ok); + if (!ok) { sleep(1); return; } + accessToken = res.json('access_token'); + }); + + if (!accessToken) return; + sleep(1); + + // ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─ + let debitAccountId = null; + let creditAccountId = null; + + group('accounts:list', () => { + const res = http.get( + `${BASE_URL}/api/accounts`, + { headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } } + ); + accountsReadDuration.add(res.timings.duration); + const ok = check(res, { + 'accounts 200': r => r.status === 200, + 'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0, + }); + crudErrorRate.add(!ok); + + if (ok) { + const accounts = res.json(); + // Pick first two distinct accounts for the journal entry + debitAccountId = accounts[0]?.id; + creditAccountId = accounts[1]?.id; + } + }); + + if (!debitAccountId || !creditAccountId) { sleep(1); return; } + sleep(1.5); + + // ── 3. Create journal entry (draft) ──────────────────────────────────── + let journalEntryId = null; + + group('journal:create', () => { + const payload = { + date: new Date().toISOString().split('T')[0], + description: `Load test entry ${uuidv4().slice(0, 8)}`, + lines: [ + { accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' }, + { accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' }, + ], + }; + + const res = http.post( + `${BASE_URL}/api/journal-entries`, + JSON.stringify(payload), + { headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } } + ); + + journalEntryDuration.add(res.timings.duration); + // Watch for WriteAccessGuard rejections (403) + writeGuardErrorRate.add(res.status === 403); + const ok = check(res, { + 'journal create 201': r => r.status === 201, + 'journal has id': r => r.json('id') !== undefined, + }); + crudErrorRate.add(!ok); + if (ok) journalEntryId = res.json('id'); + }); + + sleep(1); + + // ── 4. Post the journal entry ──────────────────────────────────────────── + if (journalEntryId) { + group('journal:post', () => { + const res = http.post( + `${BASE_URL}/api/journal-entries/${journalEntryId}/post`, + null, + { headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } } + ); + journalEntryDuration.add(res.timings.duration); + writeGuardErrorRate.add(res.status === 403); + const ok = check(res, { 'journal post 200': r => r.status === 200 }); + crudErrorRate.add(!ok); + }); + sleep(1.5); + } + + // ── 5. Generate invoice preview ───────────────────────────────────────── + let invoicePreviewOk = false; + group('invoice:preview', () => { + const res = http.post( + `${BASE_URL}/api/invoices/generate-preview`, + JSON.stringify({ period: currentYear() }), + { headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } } + ); + invoiceDuration.add(res.timings.duration); + invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 }); + crudErrorRate.add(!invoicePreviewOk); + }); + + sleep(2); // user reviews invoice preview + + // ── 6. Create a payment record ─────────────────────────────────────────── + group('payment:create', () => { + const payload = { + amount: 150.00, + date: new Date().toISOString().split('T')[0], + method: 'check', + description: `Load test payment ${uuidv4().slice(0, 8)}`, + }; + + const res = http.post( + `${BASE_URL}/api/payments`, + JSON.stringify(payload), + { headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } } + ); + paymentDuration.add(res.timings.duration); + writeGuardErrorRate.add(res.status === 403); + const ok = check(res, { + 'payment create 201 or 200': r => r.status === 201 || r.status === 200, + }); + crudErrorRate.add(!ok); + }); + + sleep(1.5); + + // ── 7. Budget vs actual (typically the heaviest read query) ───────────── + group('budget:vs-actual', () => { + const year = currentYear(); + const res = http.get( + `${BASE_URL}/api/budgets/${year}/vs-actual`, + { headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } } + ); + budgetDuration.add(res.timings.duration); + const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 }); + crudErrorRate.add(!ok); + }); + + sleep(1); + + // ── 8. Trial balance read ──────────────────────────────────────────────── + group('accounts:trial-balance', () => { + const res = http.get( + `${BASE_URL}/api/accounts/trial-balance`, + { headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } } + ); + accountsReadDuration.add(res.timings.duration); + check(res, { 'trial balance 200': r => r.status === 200 }); + }); + + sleep(1); + + // ── 9. Logout ──────────────────────────────────────────────────────────── + group('auth:logout', () => { + http.post( + `${BASE_URL}/api/auth/logout`, + null, + { headers: jsonHeaders(accessToken), tags: { name: 'logout' } } + ); + }); + + sleep(1); +} diff --git a/load-tests/cycle-template.md b/load-tests/cycle-template.md new file mode 100644 index 0000000..e2b6901 --- /dev/null +++ b/load-tests/cycle-template.md @@ -0,0 +1,117 @@ +# HOALedgerIQ – Load Test Improvement Report +**Cycle:** 001 +**Date:** YYYY-MM-DD +**Test window:** HH:MM – HH:MM UTC +**Environments:** Staging (`staging.hoaledgeriq.com`) +**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js` +**Peak VUs:** 200 (dashboard) / 100 (CRUD) +**New Relic app:** `HOALedgerIQ_App` + +--- + +## Executive Summary + +> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_ + +**Threshold breaches this cycle:** + +| Metric | Target | Actual | Status | +|--------|--------|--------|--------| +| login p95 | < 300ms | — | 🔴 / 🟢 | +| dashboard p95 | < 1000ms | — | 🔴 / 🟢 | +| budget vs-actual p95 | < 1000ms | — | 🔴 / 🟢 | +| journal entry write p95 | < 1200ms | — | 🔴 / 🟢 | +| error rate | < 1% | — | 🔴 / 🟢 | + +--- + +## Findings + +### 🔴 P0 – Fix Before Next Deploy + +#### Finding 001 – [Short title] +- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_ +- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_ +- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` — each component triggers a separate `SELECT` for `monthly_actuals`_ +- **File:** `backend/src/modules/reports/cash-flow.service.ts:83` +- **Recommended fix:** + ```typescript + // BEFORE – N+1: one query per component + for (const component of components) { + const actuals = await this.actualsRepo.findBy({ componentId: component.id }); + } + + // AFTER – batch load with WHERE IN + const actuals = await this.actualsRepo.findBy({ + componentId: In(components.map(c => c.id)) + }); + ``` +- **Expected improvement:** ~70% latency reduction on this endpoint +- **Effort:** Low (1–2 hours) + +--- + +### 🟠 P1 – Fix Within This Sprint + +#### Finding 002 – [Short title] +- **Symptom:** +- **New Relic evidence:** +- **Root cause hypothesis:** +- **File:** +- **Recommended fix:** +- **Expected improvement:** +- **Effort:** + +#### Finding 003 – [Short title] +- _(same structure)_ + +--- + +### 🟡 P2 – Backlog + +#### Finding 004 – [Short title] +- **Symptom:** +- **Root cause hypothesis:** +- **Recommended fix:** +- **Effort:** + +--- + +## Regression Net — Re-Test Criteria + +After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging: + +| Endpoint | Previous p95 | Target p95 | k6 Threshold | +|----------|-------------|------------|-------------| +| `GET /api/reports/cash-flow-forecast` | — | — | `p(95) **Claude Code update command (run after confirming fixes):** +> ```bash +> claude "Update load-tests/analysis/baseline.json with the p95 values from +> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in +> load-tests/config/environments.json staging block to match. Do not loosen +> any threshold that already passes." +> ``` + +--- + +## Baseline Delta + +| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ | +|----------|--------------|--------------|---| +| _(populated after first run)_ | — | — | — | + +--- + +## Notes & Observations + +- _Any anomalies, flaky tests, or infrastructure events during the run_ +- _Redis / BullMQ queue depth observations_ +- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_ +- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_ + +--- + +_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._ diff --git a/load-tests/environments.json b/load-tests/environments.json new file mode 100644 index 0000000..cb997a3 --- /dev/null +++ b/load-tests/environments.json @@ -0,0 +1,38 @@ +{ + "local": { + "baseUrl": "http://localhost:3000", + "thresholds": { + "auth_p95": 500, + "refresh_p95": 300, + "read_p95": 1000, + "write_p95": 1500, + "dashboard_p95": 1500, + "global_p99": 3000, + "error_rate": 0.05 + } + }, + "staging": { + "baseUrl": "https://staging.hoaledgeriq.com", + "thresholds": { + "auth_p95": 300, + "refresh_p95": 200, + "read_p95": 800, + "write_p95": 1200, + "dashboard_p95": 1000, + "global_p99": 2000, + "error_rate": 0.01 + } + }, + "production": { + "baseUrl": "https://app.hoaledgeriq.com", + "thresholds": { + "auth_p95": 200, + "refresh_p95": 150, + "read_p95": 500, + "write_p95": 800, + "dashboard_p95": 700, + "global_p99": 1500, + "error_rate": 0.005 + } + } +}