This commit is contained in:
2026-05-25 13:38:20 +07:00
parent 5a1829bc0f
commit b40568dd30
7 changed files with 948 additions and 259 deletions

View 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,
};
}