212 lines
7.3 KiB
JavaScript
212 lines
7.3 KiB
JavaScript
// 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,
|
|
};
|
|
}
|