Files
umami/tests/api/seed/dataset.ts
T

420 lines
11 KiB
TypeScript

import { SEED_IDS } from './ids';
/**
* Deterministic analytics dataset ingested through the real collection
* endpoints (/api/send, /api/batch, /api/record). Works for both Postgres and
* ClickHouse. Backdated events use `timestamp` (seconds).
*
* IPs are private on purpose: the test image has no GeoLite database and
* src/lib/detect.ts throws on a non-local IP lookup.
*/
export interface SendPayload {
type: 'event' | 'identify' | 'performance';
payload: Record<string, unknown>;
}
export interface RecordPayload {
type: 'record' | 'heatmap';
payload: Record<string, unknown>;
}
/** Events sent sequentially, chained with the `x-umami-cache` token. */
export interface Visit {
events: SendPayload[];
}
export interface Dataset {
range: { startAt: number; endAt: number };
visits: Visit[];
batch: SendPayload[];
replay: { pageview: SendPayload; record: RecordPayload; heatmap: RecordPayload };
realtime: SendPayload[];
expected: { pageviews: number; website2Pageviews: number };
distinctIds: string[];
pages: string[];
eventNames: string[];
}
export interface Persona {
id: string;
ip: string;
userAgent: string;
screen: string;
language: string;
referrer: string;
utm?: boolean;
tag?: string;
}
export const HOSTNAME = 'example.test';
export const CURRENCY = 'USD';
export const DAYS = 7;
export const EVENT_NAMES = ['signup', 'purchase', 'download'];
export const UTM_QUERY = 'utm_source=newsletter&utm_medium=email&utm_campaign=launch&gclid=abc123';
/** The first N personas send `identify` calls and carry a distinct id. */
const IDENTIFIED_PERSONAS = 3;
const DAY = 86_400;
const HOUR = 3_600;
export const PAGES = [
{ url: '/', title: 'Home' },
{ url: '/pricing', title: 'Pricing' },
{ url: '/blog/hello-world', title: 'Hello World' },
{ url: '/docs', title: 'Documentation' },
{ url: '/signup', title: 'Sign up' },
];
export const PERSONAS: Persona[] = [
{
id: 'alice',
ip: '10.0.0.1',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
screen: '1920x1080',
language: 'en-US',
referrer: 'https://www.google.com/',
utm: true,
},
{
id: 'bob',
ip: '10.0.0.2',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
screen: '2560x1440',
language: 'en-GB',
referrer: 'https://t.co/abc123',
},
{
id: 'carol',
ip: '10.0.0.3',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
screen: '1440x900',
language: 'de-DE',
referrer: '',
tag: 'beta',
},
{
id: 'dave',
ip: '10.0.0.4',
userAgent:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
screen: '412x915',
language: 'fr-FR',
referrer: 'https://www.bing.com/search?q=umami',
utm: true,
},
{
id: 'erin',
ip: '10.0.0.5',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
screen: '390x844',
language: 'es-ES',
referrer: '',
},
{
id: 'frank',
ip: '10.0.0.6',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
screen: '1366x768',
language: 'en-US',
referrer: 'https://github.com/umami-software/umami',
},
];
export function getDistinctId(persona: Persona) {
return `user-${persona.id}`;
}
function client(persona: Persona) {
return {
hostname: HOSTNAME,
language: persona.language,
screen: persona.screen,
userAgent: persona.userAgent,
ip: persona.ip,
};
}
function identity(persona: Persona, index: number) {
return index < IDENTIFIED_PERSONAS ? { id: getDistinctId(persona) } : {};
}
function pageview(
website: string,
persona: Persona,
index: number,
page: { url: string; title: string },
extra: Record<string, unknown>,
): SendPayload {
return {
type: 'event',
payload: { website, ...client(persona), ...identity(persona, index), ...page, ...extra },
};
}
function customEvent(
website: string,
persona: Persona,
index: number,
name: string,
data: Record<string, unknown>,
timestamp: number,
): SendPayload {
return {
type: 'event',
payload: {
website,
...client(persona),
...identity(persona, index),
url: '/',
title: 'Home',
name,
data,
timestamp,
},
};
}
export function buildDataset(now = Date.now()): Dataset {
const nowSec = Math.floor(now / 1000);
const range = { startAt: (nowSec - DAYS * DAY) * 1000, endAt: now };
// Backdated traffic is anchored to noon UTC of each calendar day rather than
// to `now`, so the per-persona hour offsets below never straddle a UTC day
// boundary. Specs bucket by day in UTC and assert e.g. "3 purchases on each of
// 3 distinct days"; anchoring to `now` made that fail for runs started within
// a few hours after 00:00 UTC.
const todayStart = nowSec - (nowSec % DAY);
const dayAnchor = (daysAgo: number) => todayStart - daysAgo * DAY + 12 * HOUR;
const visits: Visit[] = [];
const batch: SendPayload[] = [];
let pageviews = 0;
let website2Pageviews = 0;
// Backdated traffic: days 1..6 ago (today only receives the live events below).
for (let day = DAYS - 1; day >= 1; day--) {
PERSONAS.forEach((persona, index) => {
const start = dayAnchor(day) - (index + 1) * HOUR;
const pageCount = 2 + ((day + index) % 3);
const pages = PAGES.slice(0, pageCount);
visits.push({
events: pages.map((page, step) =>
pageview(SEED_IDS.website, persona, index, page, {
url: step === 0 && persona.utm ? `${page.url}?${UTM_QUERY}` : page.url,
referrer: step === 0 && persona.referrer ? persona.referrer : undefined,
tag: persona.tag,
timestamp: start + step * 60,
}),
),
});
pageviews += pages.length;
const afterPages = start + pageCount * 60;
if (day === 2) {
batch.push(
customEvent(
SEED_IDS.website,
persona,
index,
'signup',
{ plan: index % 2 ? 'pro' : 'free', seats: 1 + index },
afterPages,
),
);
}
if (index < IDENTIFIED_PERSONAS && [1, 3, 5].includes(day)) {
batch.push(
customEvent(
SEED_IDS.website,
persona,
index,
'purchase',
{
revenue: 49.5 + index * 10,
currency: CURRENCY,
quantity: 2,
items: ['sku-1', 'sku-2'],
orderedAt: new Date(afterPages * 1000).toISOString(),
premium: index === 0,
},
afterPages + 30,
),
);
}
if (day === 4) {
batch.push(
customEvent(
SEED_IDS.website,
persona,
index,
'download',
{ file: 'guide.pdf' },
afterPages + 60,
),
);
}
if (index < IDENTIFIED_PERSONAS && day === DAYS - 1) {
batch.push({
type: 'identify',
payload: {
website: SEED_IDS.website,
...client(persona),
id: getDistinctId(persona),
data: {
plan: 'pro',
seats: 5 + index,
tags: ['early-adopter', 'newsletter'],
signedUpAt: new Date(afterPages * 1000).toISOString(),
active: true,
},
timestamp: afterPages + 90,
},
});
}
if (day % 2 === 0) {
batch.push({
type: 'performance',
payload: {
website: SEED_IDS.website,
...client(persona),
url: '/',
title: 'Home',
lcp: 1800 + index * 100,
inp: 120 + index * 10,
cls: 0.05,
fcp: 900 + index * 50,
ttfb: 250 + index * 20,
timestamp: start + 5,
},
});
}
});
}
// Secondary website (owned by the regular user): two personas, five days.
for (let day = 5; day >= 1; day--) {
PERSONAS.slice(0, 2).forEach((persona, index) => {
const timestamp = dayAnchor(day) - (index + 1) * HOUR;
visits.push({
events: [
pageview(SEED_IDS.website2, persona, index, PAGES[day % PAGES.length], { timestamp }),
],
});
website2Pageviews += 1;
});
}
batch.push(
customEvent(
SEED_IDS.website2,
PERSONAS[0],
0,
'purchase',
{ revenue: 19.99, currency: CURRENCY, quantity: 1 },
dayAnchor(2),
),
);
// Link clicks and pixel hits (sessions are keyed by the link/pixel id).
PERSONAS.slice(0, 5).forEach((persona, index) => {
const timestamp = dayAnchor(index + 1) - 30 * 60;
batch.push({
type: 'event',
payload: { link: SEED_IDS.link, ...client(persona), url: '/', timestamp },
});
batch.push({
type: 'event',
payload: { pixel: SEED_IDS.pixel, ...client(persona), url: '/', timestamp: timestamp + 60 },
});
});
// Live traffic (no timestamp): one recorded visit plus a few realtime pageviews.
const replayPersona = PERSONAS[0];
const replay = {
pageview: pageview(SEED_IDS.website, replayPersona, 0, PAGES[0], {}),
record: {
type: 'record' as const,
payload: {
website: SEED_IDS.website,
events: [
{
type: 4,
data: { href: `https://${HOSTNAME}/`, width: 1280, height: 800 },
timestamp: now,
},
{
type: 2,
data: { node: { type: 0, childNodes: [] }, initialOffset: { left: 0, top: 0 } },
timestamp: now + 10,
},
{
type: 3,
data: { source: 1, positions: [{ x: 10, y: 20, id: 1, timeOffset: 0 }] },
timestamp: now + 20,
},
],
},
},
heatmap: {
type: 'heatmap' as const,
payload: {
website: SEED_IDS.website,
events: [
{
type: 'click',
url: `https://${HOSTNAME}/`,
x: 100,
y: 200,
pageX: 100,
pageY: 200,
pageW: 1280,
pageH: 3000,
viewportW: 1280,
viewportH: 800,
timestamp: now + 30,
},
{
type: 'scroll',
url: `https://${HOSTNAME}/`,
scrollPct: 60,
pageW: 1280,
pageH: 3000,
viewportW: 1280,
viewportH: 800,
timestamp: now + 40,
},
],
},
},
};
pageviews += 1;
const realtime = PERSONAS.slice(1).map((persona, index) =>
pageview(SEED_IDS.website, persona, index + 1, PAGES[index % PAGES.length], {}),
);
pageviews += realtime.length;
return {
range,
visits,
batch,
replay,
realtime,
expected: { pageviews, website2Pageviews },
distinctIds: PERSONAS.slice(0, IDENTIFIED_PERSONAS).map(getDistinctId),
pages: PAGES.map(page => page.url),
eventNames: EVENT_NAMES,
};
}