diff --git a/frontend/src/components/common/StatsCharts/StatsCharts.module.scss b/frontend/src/components/common/StatsCharts/StatsCharts.module.scss new file mode 100644 index 0000000000..87434f837f --- /dev/null +++ b/frontend/src/components/common/StatsCharts/StatsCharts.module.scss @@ -0,0 +1,37 @@ +.chartCard { + margin-top: 20px; + overflow: hidden; +} + +.chartCardTitle { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 12px; + margin-bottom: 20px; + + h2 { + margin: 0; + } +} + +.dateRange { + color: var(--mantine-color-gray-5); + font-size: 0.875rem; + white-space: nowrap; +} + +.chart { + width: 100%; + height: 300px; +} + +@media (max-width: 768px) { + .chartCardTitle { + flex-direction: column; + align-items: flex-start; + gap: 4px; + margin-bottom: 12px; + } +} diff --git a/frontend/src/components/common/StatsCharts/index.tsx b/frontend/src/components/common/StatsCharts/index.tsx new file mode 100644 index 0000000000..f717c29334 --- /dev/null +++ b/frontend/src/components/common/StatsCharts/index.tsx @@ -0,0 +1,87 @@ +import {ReactNode} from "react"; +import {AreaChart} from "@mantine/charts"; +import {t} from "@lingui/macro"; +import {Card} from "../Card"; +import {EventDailyStats} from "../../../types.ts"; +import {formatCurrency} from "../../../utilites/currency.ts"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; +import classes from "./StatsCharts.module.scss"; + +interface StatsChartCardProps { + dailyStats: EventDailyStats[] | undefined; + timezone: string; + dateRangeLabel: string; + syncId: string; +} + +const responsiveChartProps = { + className: classes.chart, + dataKey: "date", + withLegend: true, + legendProps: {verticalAlign: 'bottom' as const}, + styles: {legend: {justifyContent: 'center', rowGap: 4}}, + xAxisProps: {minTickGap: 24}, + yAxisProps: {width: 'auto' as const}, + tickLine: "none" as const, +}; + +const ChartCard = ({title, dateRangeLabel, children}: { + title: string; + dateRangeLabel: string; + children: ReactNode; +}) => ( + + + {title} + {dateRangeLabel && {dateRangeLabel}} + + {children} + +); + +export const ProductSalesChartCard = ({dailyStats, timezone, dateRangeLabel, syncId}: StatsChartCardProps) => ( + + ({ + date: formatDateWithLocale(stat.date, 'chartDate', timezone), + orders_created: stat.orders_created, + products_sold: stat.products_sold, + attendees_registered: stat.attendees_registered, + })) || []} + series={[ + {name: 'orders_created', color: 'blue.6', label: t`Completed Orders`}, + {name: 'products_sold', color: 'blue.2', label: t`Products Sold`}, + {name: 'attendees_registered', color: 'blue.4', label: t`Attendees Registered`}, + ]} + curveType="bump" + areaChartProps={{syncId}} + /> + +); + +export const RevenueChartCard = ({dailyStats, timezone, dateRangeLabel, syncId, currency}: StatsChartCardProps & { + currency: string; +}) => ( + + ({ + date: formatDateWithLocale(stat.date, 'chartDate', timezone), + total_fees: stat.total_fees, + total_sales_gross: stat.total_sales_gross, + total_tax: stat.total_tax, + total_refunded: stat.total_refunded, + })) || []} + valueFormatter={(value) => formatCurrency(value, currency)} + series={[ + {name: 'total_fees', label: t`Total Fees`, color: 'primary.3'}, + {name: 'total_sales_gross', label: t`Gross Sales`, color: 'grape.5'}, + {name: 'total_tax', label: t`Total Tax`, color: 'grape.7'}, + {name: 'total_refunded', label: t`Total Refunded`, color: 'red.6'}, + ]} + curveType="natural" + areaChartProps={{syncId}} + /> + +); diff --git a/misc/k6/README.md b/misc/k6/README.md new file mode 100644 index 0000000000..b39f091b3b --- /dev/null +++ b/misc/k6/README.md @@ -0,0 +1,72 @@ +# k6 load tests + +Two scripts exercising the public ticket-buying API. Both default to the local dev stack +(`https://localhost:8443/api`, self-signed certs are skipped unless `INSECURE=false`). + +| Script | What it does | +|---|---| +| `checkout-flow.js` | Full buyer funnel per iteration: event page → reserve (`POST /order`) → complete (free) or abandon → order summary (`GET /order?include=event`). Counts reservations, completions, sold-out rejections, 429s, 5xx, and per-step p95. Can create a capacity-capped product and assert nothing oversells. | +| `event-page.js` | Read-side only: hammers `GET /public/events/{id}` (optionally with `event_occurrence_id`, which bypasses the 2s quantity cache). | + +## Setup + +```bash +cd docker/development +docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap +``` + +That prints `single_event_id`, `free_product_id / price_id`, `recurring_event_id`, occurrence ids and a Bearer token. + +The dev stack's `backend/.env` sets `APP_API_RATE_LIMIT_PER_MINUTE=100000`. Against any other environment, k6 +runs from one IP and the default `api` throttle (180/min per IP) will 429 you almost immediately — either raise +that env var on the target or expect the `rate_limited` threshold to fail (which is itself a finding). + +## Checkout funnel + +Smoke (3 iterations, 1 VU) to prove the target is wired correctly: + +```bash +EVENT_ID=123 SCENARIO=smoke k6 run misc/k6/checkout-flow.js +``` + +Spike — ramp to `RATE` new buyers/second over 20s, hold for `DURATION`, ramp down — against a freshly created +product capped at `CAPACITY` tickets (needs `TOKEN` so setup can create it). This is the oversell test: when the +run ends, `tickets_completed` must be ≤ `CAPACITY`, the teardown re-reads the product with the token and checks +`quantity_sold <= initial_quantity_available`, and the remaining buyers must have received sold-out 422s rather +than 5xx or timeouts. + +```bash +EVENT_ID=123 TOKEN=... CAPACITY=200 RATE=40 DURATION=1m k6 run misc/k6/checkout-flow.js +``` + +Steady state at a fixed arrival rate against an existing product (auto-discovers the first in-stock FREE product, +or pin one with `PRODUCT_ID`/`PRICE_ID`; recurring events pick the first active occurrence or `OCCURRENCE_ID`): + +```bash +EVENT_ID=123 SCENARIO=steady RATE=20 DURATION=2m k6 run misc/k6/checkout-flow.js +``` + +Variables: `BASE_URL`, `EVENT_ID` (required), `SCENARIO` (`smoke|steady|spike`, default `spike`), `RATE` +(arrivals/sec, default 30), `DURATION` (default `1m`), `MAX_QTY` (tickets per order, 1..N, default 2), +`ABANDON_RATIO` (share of buyers who abandon after reserving, default 0.1), `PRODUCT_ID`/`PRICE_ID`, +`OCCURRENCE_ID`, `TOKEN` + `CAPACITY` (create a capped product), `INSECURE` (default true). + +Thresholds fail the run on any 429, any 5xx, any unexpected status, or p95 above 800ms (reads) / 1500ms +(reserve, complete). Expected sold-out 422s are counted in `sold_out_responses` and do not fail the run. + +## Event page + +```bash +EVENT_ID=123 RATE=100 DURATION=30s k6 run misc/k6/event-page.js +EVENT_ID=456 OCCURRENCE_ID=789 RATE=100 k6 run misc/k6/event-page.js # cache-bypassing recurring path +``` + +## Reading results + +- `step_reserve` p95 climbing with `RATE` while `step_event_page` stays flat → the per-event advisory lock in + `CreateOrderHandler` is the bottleneck (all reservations for one event serialise behind it). +- `rate_limited > 0` → the per-IP `api` throttle is biting before the database does. +- `tickets_completed > CAPACITY` or the teardown oversell check failing → a capacity race (shared capacity + assignments are the known weak spot; per-price/per-occurrence limits are enforced under the lock). +- Completed-order emails and event statistics are queued; watch the queue worker and Mailpit + (`http://localhost:8025`) to see how far delivery lags behind completion. diff --git a/misc/k6/checkout-flow.js b/misc/k6/checkout-flow.js new file mode 100644 index 0000000000..e28c2d398e --- /dev/null +++ b/misc/k6/checkout-flow.js @@ -0,0 +1,321 @@ +import http from 'k6/http'; +import {check, fail} from 'k6'; +import {Counter, Trend} from 'k6/metrics'; +import {randomIntBetween, uuidv4} from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; +import {textSummary} from 'https://jslib.k6.io/k6-summary/0.1.0/index.js'; + +const BASE_URL = (__ENV.BASE_URL || 'https://localhost:8443/api').replace(/\/$/, ''); +const EVENT_ID = __ENV.EVENT_ID; +const TOKEN = __ENV.TOKEN; +const CAPACITY = __ENV.CAPACITY ? Number(__ENV.CAPACITY) : null; +const ABANDON_RATIO = Number(__ENV.ABANDON_RATIO ?? 0.1); +const MAX_QTY = Number(__ENV.MAX_QTY ?? 2); +const RATE = Number(__ENV.RATE ?? 30); +const DURATION = __ENV.DURATION || '1m'; +const SCENARIO = __ENV.SCENARIO || 'spike'; + +const JSON_HEADERS = {'Content-Type': 'application/json', Accept: 'application/json'}; + +const ordersReserved = new Counter('orders_reserved'); +const ordersCompleted = new Counter('orders_completed'); +const ordersAbandoned = new Counter('orders_abandoned'); +const ticketsCompleted = new Counter('tickets_completed'); +const soldOut = new Counter('sold_out_responses'); +const rateLimited = new Counter('rate_limited'); +const serverErrors = new Counter('server_errors'); +const unexpected = new Counter('unexpected_responses'); + +const tEvent = new Trend('step_event_page', true); +const tReserve = new Trend('step_reserve', true); +const tComplete = new Trend('step_complete', true); +const tOrder = new Trend('step_order_get', true); + +const scenarios = { + smoke: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 3, + }, + steady: { + executor: 'constant-arrival-rate', + rate: RATE, + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: Math.max(20, RATE * 2), + maxVUs: Math.max(100, RATE * 10), + }, + spike: { + executor: 'ramping-arrival-rate', + startRate: 1, + timeUnit: '1s', + preAllocatedVUs: Math.max(20, RATE * 2), + maxVUs: Math.max(100, RATE * 10), + stages: [ + {target: RATE, duration: '20s'}, + {target: RATE, duration: DURATION}, + {target: 0, duration: '20s'}, + ], + }, +}; + +export const options = { + insecureSkipTLSVerify: __ENV.INSECURE !== 'false', + scenarios: {[SCENARIO]: scenarios[SCENARIO]}, + thresholds: { + rate_limited: ['count==0'], + server_errors: ['count==0'], + unexpected_responses: ['count==0'], + step_event_page: ['p(95)<800'], + step_reserve: ['p(95)<1500'], + step_complete: ['p(95)<1500'], + step_order_get: ['p(95)<800'], + http_req_failed: ['rate<0.01'], + }, +}; + +http.setResponseCallback(http.expectedStatuses(200, 201, 204, 422)); + +function publicEvent(occurrenceId) { + const query = occurrenceId ? `?event_occurrence_id=${occurrenceId}` : ''; + return http.get(`${BASE_URL}/public/events/${EVENT_ID}${query}`, { + headers: JSON_HEADERS, + tags: {step: 'event_page'}, + }); +} + +function createCappedProduct() { + const auth = {...JSON_HEADERS, Authorization: `Bearer ${TOKEN}`}; + const categories = http.get(`${BASE_URL}/events/${EVENT_ID}/product-categories`, {headers: auth}); + if (categories.status !== 200) { + fail(`Could not list product categories (${categories.status}): ${categories.body}`); + } + const categoryId = categories.json('data.0.id'); + + const res = http.post(`${BASE_URL}/events/${EVENT_ID}/products`, JSON.stringify({ + title: `k6 load test ${new Date().toISOString()}`, + type: 'FREE', + product_type: 'TICKET', + product_category_id: categoryId, + max_per_order: MAX_QTY, + prices: [{price: 0, initial_quantity_available: CAPACITY}], + }), {headers: auth}); + + if (res.status !== 201) { + fail(`Could not create capped product (${res.status}): ${res.body}`); + } + + return {productId: res.json('data.id'), priceId: res.json('data.prices.0.id')}; +} + +function discoverFreeProduct(event) { + const categories = event.product_categories || []; + for (const category of categories) { + for (const product of category.products || []) { + const price = (product.prices || [])[0]; + if (product.type === 'FREE' && price && !product.is_sold_out && !product.is_addon_only) { + return {productId: product.id, priceId: price.id, quantityAvailable: product.quantity_available ?? null}; + } + } + } + return null; +} + +function discoverOccurrence(event) { + if (__ENV.OCCURRENCE_ID) { + return Number(__ENV.OCCURRENCE_ID); + } + const occurrence = (event.occurrences || []).find((o) => o.is_active && !o.is_past); + return occurrence ? occurrence.id : null; +} + +export function setup() { + if (!EVENT_ID) { + fail('EVENT_ID is required'); + } + + const res = publicEvent(null); + if (res.status !== 200) { + fail(`Could not load public event ${EVENT_ID} (${res.status}): ${res.body}`); + } + const event = res.json('data'); + + let product; + if (__ENV.PRODUCT_ID && __ENV.PRICE_ID) { + product = {productId: Number(__ENV.PRODUCT_ID), priceId: Number(__ENV.PRICE_ID)}; + } else if (TOKEN && CAPACITY) { + product = createCappedProduct(); + } else { + product = discoverFreeProduct(event); + } + + if (!product) { + fail('No free, in-stock product found on the event. Pass PRODUCT_ID/PRICE_ID, or TOKEN + CAPACITY to create one.'); + } + + const target = { + eventId: Number(EVENT_ID), + occurrenceId: discoverOccurrence(event), + ...product, + }; + + console.log(`Target: ${JSON.stringify(target)}`); + return target; +} + +function classify(res, counterOnOk) { + if (res.status === 429) { + rateLimited.add(1); + return false; + } + if (res.status >= 500) { + serverErrors.add(1); + return false; + } + if (res.status === 422) { + const body = res.body || ''; + if (/sold out|maximum number/i.test(body)) { + soldOut.add(1); + return false; + } + } + if (counterOnOk.includes(res.status)) { + return true; + } + unexpected.add(1); + console.error(`Unexpected ${res.request.method} ${res.url} -> ${res.status}: ${String(res.body).slice(0, 300)}`); + return false; +} + +export default function (target) { + const {eventId, occurrenceId, productId, priceId} = target; + http.cookieJar().clear(BASE_URL); + + const eventRes = publicEvent(occurrenceId); + tEvent.add(eventRes.timings.duration); + if (!classify(eventRes, [200])) { + return; + } + + const quantity = randomIntBetween(1, MAX_QTY); + const line = {product_id: productId, quantities: [{price_id: priceId, quantity}]}; + if (occurrenceId) { + line.event_occurrence_id = occurrenceId; + } + + const reserveRes = http.post( + `${BASE_URL}/public/events/${eventId}/order`, + JSON.stringify({products: [line]}), + {headers: JSON_HEADERS, tags: {step: 'reserve'}}, + ); + tReserve.add(reserveRes.timings.duration); + if (!classify(reserveRes, [201])) { + return; + } + ordersReserved.add(1); + + const order = reserveRes.json('data'); + const session = order.session_identifier; + const orderUrl = `${BASE_URL}/public/events/${eventId}/order/${order.short_id}`; + + if (Math.random() < ABANDON_RATIO) { + const abandonRes = http.post(`${orderUrl}/abandon?session_identifier=${session}`, null, { + headers: JSON_HEADERS, + tags: {step: 'abandon'}, + }); + if (classify(abandonRes, [200, 204])) { + ordersAbandoned.add(1); + } + return; + } + + const email = `k6-${uuidv4()}@load.test`; + const buyer = {first_name: 'Load', last_name: 'Test', email, email_confirmation: email}; + const attendees = order.order_items.flatMap((item) => + Array.from({length: item.quantity}, () => ({ + product_id: item.product_id, + product_price_id: item.product_price_id, + ...buyer, + })), + ); + + const completeRes = http.put( + `${orderUrl}?session_identifier=${session}`, + JSON.stringify({order: buyer, products: attendees}), + {headers: JSON_HEADERS, tags: {step: 'complete'}}, + ); + tComplete.add(completeRes.timings.duration); + if (!classify(completeRes, [200])) { + return; + } + + const completed = check(completeRes, { + 'order completed': (r) => r.json('data.status') === 'COMPLETED', + }); + if (completed) { + ordersCompleted.add(1); + ticketsCompleted.add(attendees.length); + } + + const orderRes = http.get(`${orderUrl}?session_identifier=${session}&include=event`, { + headers: JSON_HEADERS, + tags: {step: 'order_get'}, + }); + tOrder.add(orderRes.timings.duration); + classify(orderRes, [200]); +} + +export function teardown(target) { + const res = publicEvent(target.occurrenceId); + if (res.status !== 200) { + console.error(`Teardown: could not reload event (${res.status})`); + return; + } + const event = res.json('data'); + for (const category of event.product_categories || []) { + const product = (category.products || []).find((p) => p.id === target.productId); + if (product) { + console.log(`After run: product ${product.id} quantity_available=${product.quantity_available ?? 'unlimited'} is_sold_out=${product.is_sold_out}`); + } + } + + if (TOKEN) { + const auth = {...JSON_HEADERS, Authorization: `Bearer ${TOKEN}`}; + const product = http.get(`${BASE_URL}/events/${target.eventId}/products/${target.productId}`, {headers: auth}); + if (product.status === 200) { + const price = (product.json('data.prices') || []).find((p) => p.id === target.priceId); + const sold = price?.quantity_sold ?? product.json('data.quantity_sold'); + const cap = price?.initial_quantity_available ?? null; + console.log(`After run (authoritative): price ${target.priceId} quantity_sold=${sold} initial_quantity_available=${cap ?? 'unlimited'}`); + check(product, {'no oversell (quantity_sold <= capacity)': () => cap === null || sold <= cap}); + } + } +} + +export function handleSummary(data) { + const count = (name) => data.metrics[name]?.values?.count ?? 0; + const p95 = (name) => Math.round(data.metrics[name]?.values?.['p(95)'] ?? 0); + + const lines = [ + '', + '=== Checkout funnel summary ===', + `orders reserved: ${count('orders_reserved')}`, + `orders completed: ${count('orders_completed')}`, + `orders abandoned: ${count('orders_abandoned')}`, + `tickets completed: ${count('tickets_completed')}${CAPACITY ? ` (capacity ${CAPACITY})` : ''}`, + `sold-out responses: ${count('sold_out_responses')}`, + `429 rate limited: ${count('rate_limited')}`, + `5xx server errors: ${count('server_errors')}`, + `unexpected responses: ${count('unexpected_responses')}`, + `p95 event page: ${p95('step_event_page')} ms`, + `p95 reserve: ${p95('step_reserve')} ms`, + `p95 complete: ${p95('step_complete')} ms`, + `p95 order get: ${p95('step_order_get')} ms`, + ]; + + if (CAPACITY && count('tickets_completed') > CAPACITY) { + lines.push(`!!! OVERSOLD: ${count('tickets_completed')} tickets completed against a capacity of ${CAPACITY}`); + } + + lines.push(''); + return {stdout: textSummary(data, {indent: ' ', enableColors: true}) + '\n' + lines.join('\n') + '\n'}; +} diff --git a/misc/k6/event-hompage-load-test.js b/misc/k6/event-hompage-load-test.js deleted file mode 100644 index c4679e512d..0000000000 --- a/misc/k6/event-hompage-load-test.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { check, sleep } from 'k6'; - -export let options = { - vus: 50, - duration: '5s', -}; - -export default function () { - let res = http.get('https://api.hi.events/public/events/2', { - headers: { - 'Accept': 'application/json', - }, - }); - - check(res, { - 'status is 200': (r) => r.status === 200, - 'response time < 400ms': (r) => r.timings.duration < 400, - 'response is JSON': (r) => r.headers['Content-Type'] && r.headers['Content-Type'].includes('application/json'), - }); - - sleep(1); -} diff --git a/misc/k6/event-page.js b/misc/k6/event-page.js new file mode 100644 index 0000000000..4a848d3557 --- /dev/null +++ b/misc/k6/event-page.js @@ -0,0 +1,46 @@ +import http from 'k6/http'; +import {check} from 'k6'; +import {Counter} from 'k6/metrics'; + +const BASE_URL = (__ENV.BASE_URL || 'https://localhost:8443/api').replace(/\/$/, ''); +const EVENT_ID = __ENV.EVENT_ID; +const OCCURRENCE_ID = __ENV.OCCURRENCE_ID; +const RATE = Number(__ENV.RATE ?? 50); +const DURATION = __ENV.DURATION || '30s'; + +const rateLimited = new Counter('rate_limited'); + +export const options = { + insecureSkipTLSVerify: __ENV.INSECURE !== 'false', + scenarios: { + event_page: { + executor: 'constant-arrival-rate', + rate: RATE, + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: Math.max(20, RATE * 2), + maxVUs: Math.max(100, RATE * 10), + }, + }, + thresholds: { + rate_limited: ['count==0'], + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<500'], + }, +}; + +export default function () { + const query = OCCURRENCE_ID ? `?event_occurrence_id=${OCCURRENCE_ID}` : ''; + const res = http.get(`${BASE_URL}/public/events/${EVENT_ID}${query}`, { + headers: {Accept: 'application/json'}, + }); + + if (res.status === 429) { + rateLimited.add(1); + } + + check(res, { + 'status is 200': (r) => r.status === 200, + 'has product categories': (r) => Array.isArray(r.json('data.product_categories')), + }); +}