// k6 generator -- backfills events with timestamps spread over the last N days // so the analytics console can show a realistic cohort retention matrix. // // How it works // 1. setup() pre-computes a schedule: each of S shoppers gets a "first day" // between [-N, 0], then revisits later days with a decay probability. // 2. The default function fires one scheduled event per iteration; we run // `shared-iterations` so all VUs collaborate to drain the schedule. // // We deliberately omit `sentAt` from the payload. The ingest's 24h late-event // check is on sent_at (default = now when omitted), not on timestamp; so the // timestamp column lands in the past while the request itself looks live. // // Usage: // k6 run tests/k6/cohort.js // k6 run -e DAYS=14 -e SHOPPERS=80 -e VUS=10 tests/k6/cohort.js import http from 'k6/http'; import { check } from 'k6'; import encoding from 'k6/encoding'; import { randomItem, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; const BASE = __ENV.BASE ?? 'http://localhost:3049'; const WRITE_KEY = __ENV.WRITE_KEY ?? 'cdp_dev_writekey_1234567890'; const SHOPPERS = parseInt(__ENV.SHOPPERS ?? '50', 10); // unique users const DAYS = parseInt(__ENV.DAYS ?? '7', 10); // cohort window const VUS = parseInt(__ENV.VUS ?? '5', 10); const AUTH = 'Basic ' + encoding.b64encode(`${WRITE_KEY}:`); // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const PRODUCTS = [ { id: 'sku_alpha', name: 'Alpha Hoodie', category: 'apparel', brand: 'CDP', price: 49.0 }, { id: 'sku_beta', name: 'Beta Mug', category: 'drinkware', brand: 'CDP', price: 12.5 }, { id: 'sku_gamma', name: 'Gamma Backpack', category: 'bags', brand: 'CDP', price: 89.0 }, { id: 'sku_delta', name: 'Delta Sneakers', category: 'footwear', brand: 'Athleta', price: 129.0 }, { id: 'sku_eps', name: 'Epsilon Headset', category: 'electronics',brand: 'Sonix', price: 199.0 }, ]; const PLANS = ['free', 'pro', 'team']; const COUNTRIES = ['US', 'VN', 'GB', 'SG', 'JP', 'DE', 'FR']; // --------------------------------------------------------------------------- // Pre-compute schedule (runs once before any VU starts) // --------------------------------------------------------------------------- function rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function isoOnDay(daysAgo, hourSeed) { // anchor at "today 12:00 UTC" minus N days, jitter a few hours so events // spread across the day rather than clumping at the same minute. const d = new Date(); d.setUTCHours(12, 0, 0, 0); d.setUTCDate(d.getUTCDate() - daysAgo); d.setUTCHours(d.getUTCHours() + (hourSeed % 9) - 4); d.setUTCMinutes(d.getUTCMinutes() + rand(0, 59)); return d.toISOString(); } export function setup() { const events = []; for (let i = 1; i <= SHOPPERS; i++) { const userId = `c_${String(i).padStart(3, '0')}`; const traits = { email: `cohort${i}@example.com`, plan: pick(PLANS), country: pick(COUNTRIES), }; // first day = day -N .. day -1 (no first-day-today so retention makes sense) const firstDay = rand(1, DAYS - 1); // Day 0 of this user's lifecycle = signup behavior. push(events, userId, traits, firstDay, 'Product Viewed', null); if (Math.random() < 0.7) push(events, userId, traits, firstDay, 'Product Added', null); if (Math.random() < 0.4) push(events, userId, traits, firstDay, 'Order Completed', null); // Later days: decaying return probability. for (let d = firstDay - 1; d >= 0; d--) { const since = firstDay - d; // days since first const pReturn = Math.max(0.05, 0.85 - 0.15 * since); // 85% → 5% if (Math.random() < pReturn) { push(events, userId, traits, d, 'Product Viewed', null); if (Math.random() < 0.3) push(events, userId, traits, d, 'Product Added', null); if (Math.random() < 0.15) push(events, userId, traits, d, 'Order Completed', null); } } } // Shuffle so partition keys spread evenly. for (let i = events.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [events[i], events[j]] = [events[j], events[i]]; } return { events }; } function push(events, userId, traits, daysAgo, eventName, _) { events.push({ userId, traits, daysAgo, eventName, seed: events.length, }); } // --------------------------------------------------------------------------- // Scenario // --------------------------------------------------------------------------- export const options = { scenarios: { backfill: { executor: 'shared-iterations', vus: VUS, // Set this big enough; setup() decides the real cap. We just stop once // we run out of events (returns early in default()). iterations: 20_000, maxDuration: '5m', }, }, thresholds: { http_req_failed: ['rate<0.02'], checks: ['rate>0.98'], }, }; export default function (data) { const ev = data.events[__ITER]; if (!ev) return; // schedule exhausted const ts = isoOnDay(ev.daysAgo, ev.seed); const product = randomItem(PRODUCTS); const messageId = `cohort_${ev.userId}_${ev.seed}_${Date.now()}`; let properties; switch (ev.eventName) { case 'Product Viewed': properties = productProps(product); break; case 'Product Added': properties = { ...productProps(product), quantity: randomIntBetween(1, 3) }; break; case 'Order Completed': properties = orderProps(); break; } const payload = JSON.stringify({ type: 'track', messageId, userId: ev.userId, anonymousId: `anon_${ev.userId}`, event: ev.eventName, properties, traits: ev.traits, // event time in the past... timestamp: ts, // ...request time intentionally omitted so the ingest's late-event guard // (which checks sent_at, not timestamp) does not reject us. context: { library_name: 'k6-cohort-sim', library_version: '0.1.0', ip: '127.0.0.1', userAgent: 'k6/cohort', locale: 'en-US', }, }); const res = http.post(`${BASE}/v1/track`, payload, { headers: { 'Content-Type': 'application/json', Authorization: AUTH }, tags: { event: ev.eventName }, }); check(res, { 'status 200': (r) => r.status === 200, 'body ok': (r) => r.json('ok') === true, }); } function productProps(p) { return { product_id: p.id, sku: p.id, name: p.name, category: p.category, brand: p.brand, price: p.price, currency: 'USD', }; } function orderProps() { const lines = []; const n = randomIntBetween(1, 3); let total = 0; for (let i = 0; i < n; i++) { const p = randomItem(PRODUCTS); const qty = randomIntBetween(1, 2); total += p.price * qty; lines.push({ product_id: p.id, sku: p.id, name: p.name, price: p.price, quantity: qty }); } return { order_id: `ord_${Date.now()}_${randomIntBetween(1000, 9999)}`, revenue: Number(total.toFixed(2)), currency: 'USD', tax: Number((total * 0.08).toFixed(2)), shipping: 5, total: Number((total + total * 0.08 + 5).toFixed(2)), products: lines, }; }