ok
This commit is contained in:
211
ingestion/tests/k6/cohort.js
Normal file
211
ingestion/tests/k6/cohort.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user