Skip to content

Fifty shoppers

One session makes a dull funnel. The generator drives 50 headless Chromium sessions through the shop. Each one is a real browser running the real SDK, so its page views, events and replay reach ouo11y as a person’s would.

Each session follows one persona, with random pauses. A seed makes the mix the same on every run, so the script can print how many sessions must reach each step: the answer to check Grafana against.

Persona Sessions What it does Last funnel step it reaches
bounce 5 opens / and leaves 1. /
deep link 5 lands on /products/2 from an “ad”, buys none: never opens /
out of order 3 opens a product, adds to cart, then goes to / 1. /
browse 9 opens a product, sometimes a second 2. /products/*
cart 8 adds to cart once or twice 3. add_to_cart
abandon 6 reaches checkout, never pays 4. /checkout
declined 4 pays with card ...0002, declined 4. /checkout
buyer 10 sometimes goes back home first, then buys 5. order_paid

In the fourth terminal:

Terminal window
cd ~/Desktop/tiny-shop-funnels/generate-sessions
generate-sessions/package.json
generate-sessions/package.json
{
"name": "tiny-shop-funnels-generate-sessions",
"private": true,
"version": "1.0.0",
"description": "ouo quickstart: drives ~50 real browser sessions through the shop so the funnel has shape",
"type": "module",
"scripts": {
"generate": "node generate.mjs"
},
"engines": {
"node": ">=24"
},
"devDependencies": {
"playwright": "1.64.1"
}
}

PERSONAS holds each persona’s weight, the step it reaches and what it does. The last lines count, per step, the sessions whose persona reaches it.

generate-sessions/generate.mjs
generate-sessions/generate.mjs
// Drives ~50 sessions through the running shop in headless Chromium, each one a real browser with
// the real SDK, so page views, custom events and replays reach ouo exactly as from people.
// Each session follows one persona; the script prints how many sessions should reach each funnel
// step, so you can check the funnel in Grafana against it.
//
// npm install && npx playwright install chromium # once
// npm run generate # SHOP_URL=http://localhost:5173 by default
import { chromium } from "playwright";
const SHOP = process.env.SHOP_URL || "http://localhost:5173";
const SESSIONS = Number(process.env.SESSIONS || 50);
const PARALLEL = Number(process.env.PARALLEL || 5);
// The funnel the README builds: / -> /products/* -> add_to_cart -> /checkout -> order_paid.
const STEPS = ["page:/", "page:/products/*", "event:add_to_cart", "page:/checkout", "event:order_paid"];
// Persona: weight (share of sessions), the funnel step it reaches (0: never enters) and what it does.
const PERSONAS = {
bounce: { weight: 10, reaches: 1, run: async (s) => { await s.open("/"); } },
// Lands on a product from a link, never sees the home page: not in the funnel (step 1 is /).
deepLink: { weight: 10, reaches: 0, run: async (s) => { await s.open("/products/2"); await s.add(); await s.checkout(); await s.pay("4242"); } },
// Adds to cart first and visits / afterwards: the order matters, so it only reaches step 1.
outOfOrder: { weight: 6, reaches: 1, run: async (s) => { await s.open("/products/1"); await s.add(); await s.home(); } },
browse: { weight: 18, reaches: 2, run: async (s) => { await s.open("/"); await s.product(); if (s.coin()) { await s.home(); await s.product(); } } },
cart: { weight: 16, reaches: 3, run: async (s) => { await s.open("/"); await s.product(); await s.add(); if (s.coin()) await s.add(); } },
abandon: { weight: 12, reaches: 4, run: async (s) => { await s.open("/"); await s.product(); await s.add(); await s.checkout(); } },
declined: { weight: 8, reaches: 4, run: async (s) => { await s.open("/"); await s.product(); await s.add(); await s.checkout(); await s.pay("0002"); } },
buyer: { weight: 20, reaches: 5, run: async (s) => { await s.open("/"); await s.product(); if (s.coin()) { await s.home(); await s.product(); } await s.add(); await s.checkout(); await s.pay("4242"); } },
};
// Deterministic: the same seed gives the same personas and pauses (Mulberry32).
let seed = Number(process.env.SEED || 7);
function rand() {
seed = (seed + 0x6d2b79f5) | 0;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function pickPersonas(n) {
// Largest remainder: the counts follow the weights exactly, then the order is shuffled.
const names = Object.keys(PERSONAS);
const total = names.reduce((s, k) => s + PERSONAS[k].weight, 0);
const exact = names.map((k) => (PERSONAS[k].weight * n) / total);
const counts = exact.map(Math.floor);
const order = exact.map((x, i) => [x - counts[i], i]).sort((a, b) => b[0] - a[0]);
for (let i = 0; counts.reduce((a, b) => a + b, 0) < n; i++) counts[order[i][1]]++;
const list = names.flatMap((k, i) => Array(counts[i]).fill(k));
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
}
return list;
}
const pause = (page, min, max) => page.waitForTimeout(min + rand() * (max - min));
function session(page) {
const s = {
coin: () => rand() < 0.5,
open: async (path) => {
await page.goto(SHOP + path);
await page.waitForLoadState("networkidle");
await pause(page, 300, 1500);
},
home: async () => {
await page.getByRole("link", { name: "Tiny Shop" }).click();
await pause(page, 300, 1200);
},
product: async () => {
const links = page.locator("ul li a");
await links.first().waitFor();
await links.nth(Math.floor(rand() * (await links.count()))).click();
await pause(page, 800, 4000); // reading the product page
},
add: async () => {
await page.getByRole("button", { name: /Add to cart/ }).click();
await pause(page, 300, 1500);
},
checkout: async () => {
await page.getByTestId("cart-link").click();
await pause(page, 300, 1200);
await page.getByRole("button", { name: "Go to checkout" }).click();
await pause(page, 1000, 5000); // finding the card
},
pay: async (card) => {
await page.getByPlaceholder("card number").fill(`4000 0000 0000 ${card}`);
await page.getByRole("button", { name: "Pay" }).click();
await page.waitForLoadState("networkidle");
await pause(page, 500, 1500);
},
};
return s;
}
async function runOne(browser, persona, i) {
const context = await browser.newContext(); // fresh storage: a new user and a new session
const page = await context.newPage();
try {
await PERSONAS[persona].run(session(page));
// Leaving the page flushes the SDK's last batch (pagehide) and ends the session.
await page.goto("about:blank");
await page.waitForTimeout(1000);
} catch (err) {
console.error(`session ${i} (${persona}) failed: ${err.message}`);
return false;
} finally {
await context.close();
}
console.log(`session ${String(i + 1).padStart(2)} ${persona}`);
return true;
}
const personas = pickPersonas(SESSIONS);
const browser = await chromium.launch();
const ok = [];
let next = 0;
await Promise.all(
Array.from({ length: PARALLEL }, async () => {
while (next < personas.length) {
const i = next++;
if (await runOne(browser, personas[i], i)) ok.push(personas[i]);
}
})
);
await browser.close();
// The hand count: sessions reaching each step, from the personas that ran.
console.log(`\n${ok.length} sessions sent. Expected funnel ${STEPS.join(" -> ")}:`);
let prev;
STEPS.forEach((step, i) => {
const n = ok.filter((p) => PERSONAS[p].reaches > i).length;
const drop = prev === undefined ? "" : ` dropped ${prev - n}`;
console.log(` ${i + 1}. ${step.padEnd(20)} ${String(n).padStart(3)} sessions${drop}`);
prev = n;
});
console.log(` (not entered: ${ok.filter((p) => PERSONAS[p].reaches === 0).length} sessions that never opened /)`);
Terminal window
npm install
Terminal window
npx playwright install chromium
Terminal window
npm run generate

It prints one line per session as it finishes. After about a minute it ends with:

50 sessions sent. Expected funnel page:/ -> page:/products/* -> event:add_to_cart -> page:/checkout -> event:order_paid:
1. page:/ 45 sessions
2. page:/products/* 37 sessions dropped 8
3. event:add_to_cart 28 sessions dropped 9
4. page:/checkout 20 sessions dropped 8
5. event:order_paid 10 sessions dropped 10
(not entered: 5 sessions that never opened /)

That is the answer Grafana must give, plus your own purchase from the Instrument page. Drawn as a funnel:

  1. 1Pageis/
    100%45 sessions
  2. 2Pagestarts with/products/
    82.2%37 sessions82.2% of previous step
  3. 3Eventadd_to_cart
    62.2%28 sessions75.7% of previous step
  4. 4Pageis/checkout
    44.4%20 sessions71.4% of previous step
  5. steepest fall
    5Eventorder_paid
    22.2%10 sessions50% of previous step
The generator's count. Click a drop-off for the personas behind it.

Run it again and every number doubles: each run adds the same 45 / 37 / 28 / 20 / 10.

Next: build and read the funnel.