feat(dev): add sample data generator script

Adds a CLI tool to generate realistic analytics data for local development and testing.
Creates two demo websites with varying traffic patterns and realistic user behavior distributions.
This commit is contained in:
Arthur Sepiol 2025-12-02 13:43:59 +03:00
parent a19b92a5cb
commit b7807ed466
13 changed files with 1645 additions and 2 deletions

View file

@ -0,0 +1,191 @@
import { uuid, addSeconds, randomInt } from '../utils.js';
import { getRandomReferrer } from '../distributions/referrers.js';
import type { SessionData } from './sessions.js';
export const EVENT_TYPE = {
pageView: 1,
customEvent: 2,
} as const;
export interface PageConfig {
path: string;
title: string;
weight: number;
avgTimeOnPage: number;
}
export interface CustomEventConfig {
name: string;
weight: number;
pages?: string[];
data?: Record<string, string[] | number[]>;
}
export interface JourneyConfig {
pages: string[];
weight: number;
}
export interface EventData {
id: string;
websiteId: string;
sessionId: string;
visitId: string;
eventType: number;
urlPath: string;
urlQuery: string | null;
pageTitle: string | null;
hostname: string;
referrerDomain: string | null;
referrerPath: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmContent: string | null;
utmTerm: string | null;
gclid: string | null;
fbclid: string | null;
eventName: string | null;
tag: string | null;
createdAt: Date;
}
export interface EventDataEntry {
id: string;
websiteId: string;
websiteEventId: string;
dataKey: string;
stringValue: string | null;
numberValue: number | null;
dateValue: Date | null;
dataType: number;
createdAt: Date;
}
export interface SiteConfig {
hostname: string;
pages: PageConfig[];
journeys: JourneyConfig[];
customEvents: CustomEventConfig[];
}
function getPageTitle(pages: PageConfig[], path: string): string | null {
const page = pages.find(p => p.path === path);
return page?.title ?? null;
}
function getPageTimeOnPage(pages: PageConfig[], path: string): number {
const page = pages.find(p => p.path === path);
return page?.avgTimeOnPage ?? 30;
}
export function generateEventsForSession(
session: SessionData,
siteConfig: SiteConfig,
journey: string[],
): { events: EventData[]; eventDataEntries: EventDataEntry[] } {
const events: EventData[] = [];
const eventDataEntries: EventDataEntry[] = [];
const visitId = uuid();
let currentTime = session.createdAt;
const referrer = getRandomReferrer();
for (let i = 0; i < journey.length; i++) {
const pagePath = journey[i];
const isFirstPage = i === 0;
const eventId = uuid();
const pageTitle = getPageTitle(siteConfig.pages, pagePath);
events.push({
id: eventId,
websiteId: session.websiteId,
sessionId: session.id,
visitId,
eventType: EVENT_TYPE.pageView,
urlPath: pagePath,
urlQuery: null,
pageTitle,
hostname: siteConfig.hostname,
referrerDomain: isFirstPage ? referrer.domain : null,
referrerPath: isFirstPage ? referrer.path : null,
utmSource: isFirstPage ? referrer.utmSource : null,
utmMedium: isFirstPage ? referrer.utmMedium : null,
utmCampaign: isFirstPage ? referrer.utmCampaign : null,
utmContent: isFirstPage ? referrer.utmContent : null,
utmTerm: isFirstPage ? referrer.utmTerm : null,
gclid: isFirstPage ? referrer.gclid : null,
fbclid: isFirstPage ? referrer.fbclid : null,
eventName: null,
tag: null,
createdAt: currentTime,
});
// Check for custom events on this page
for (const customEvent of siteConfig.customEvents) {
// Check if this event can occur on this page
if (customEvent.pages && !customEvent.pages.includes(pagePath)) {
continue;
}
// Random chance based on weight
if (Math.random() < customEvent.weight) {
currentTime = addSeconds(currentTime, randomInt(2, 15));
const customEventId = uuid();
events.push({
id: customEventId,
websiteId: session.websiteId,
sessionId: session.id,
visitId,
eventType: EVENT_TYPE.customEvent,
urlPath: pagePath,
urlQuery: null,
pageTitle,
hostname: siteConfig.hostname,
referrerDomain: null,
referrerPath: null,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
utmTerm: null,
gclid: null,
fbclid: null,
eventName: customEvent.name,
tag: null,
createdAt: currentTime,
});
// Generate event data if configured
if (customEvent.data) {
for (const [key, values] of Object.entries(customEvent.data)) {
const value = values[Math.floor(Math.random() * values.length)];
const isNumber = typeof value === 'number';
eventDataEntries.push({
id: uuid(),
websiteId: session.websiteId,
websiteEventId: customEventId,
dataKey: key,
stringValue: isNumber ? null : String(value),
numberValue: isNumber ? value : null,
dateValue: null,
dataType: isNumber ? 2 : 1, // 1 = string, 2 = number
createdAt: currentTime,
});
}
}
}
}
// Time spent on page before navigating
const timeOnPage = getPageTimeOnPage(siteConfig.pages, pagePath);
const variance = Math.floor(timeOnPage * 0.5);
const actualTime = timeOnPage + randomInt(-variance, variance);
currentTime = addSeconds(currentTime, Math.max(5, actualTime));
}
return { events, eventDataEntries };
}

View file

@ -0,0 +1,65 @@
import { uuid, randomFloat } from '../utils.js';
import type { EventData } from './events.js';
export interface RevenueConfig {
eventName: string;
minAmount: number;
maxAmount: number;
currency: string;
weight: number;
}
export interface RevenueData {
id: string;
websiteId: string;
sessionId: string;
eventId: string;
eventName: string;
currency: string;
revenue: number;
createdAt: Date;
}
export function generateRevenue(event: EventData, config: RevenueConfig): RevenueData | null {
if (event.eventName !== config.eventName) {
return null;
}
if (Math.random() > config.weight) {
return null;
}
const revenue = randomFloat(config.minAmount, config.maxAmount);
return {
id: uuid(),
websiteId: event.websiteId,
sessionId: event.sessionId,
eventId: event.id,
eventName: event.eventName!,
currency: config.currency,
revenue: Math.round(revenue * 100) / 100, // Round to 2 decimal places
createdAt: event.createdAt,
};
}
export function generateRevenueForEvents(
events: EventData[],
configs: RevenueConfig[],
): RevenueData[] {
const revenueEntries: RevenueData[] = [];
for (const event of events) {
if (!event.eventName) continue;
for (const config of configs) {
const revenue = generateRevenue(event, config);
if (revenue) {
revenueEntries.push(revenue);
break; // Only one revenue per event
}
}
}
return revenueEntries;
}

View file

@ -0,0 +1,52 @@
import { uuid } from '../utils.js';
import { getRandomDevice } from '../distributions/devices.js';
import { getRandomGeo, getRandomLanguage } from '../distributions/geographic.js';
import { generateTimestampForDay } from '../distributions/temporal.js';
export interface SessionData {
id: string;
websiteId: string;
browser: string;
os: string;
device: string;
screen: string;
language: string;
country: string;
region: string;
city: string;
createdAt: Date;
}
export function createSession(websiteId: string, day: Date): SessionData {
const deviceInfo = getRandomDevice();
const geo = getRandomGeo();
const language = getRandomLanguage();
const createdAt = generateTimestampForDay(day);
return {
id: uuid(),
websiteId,
browser: deviceInfo.browser,
os: deviceInfo.os,
device: deviceInfo.device,
screen: deviceInfo.screen,
language,
country: geo.country,
region: geo.region,
city: geo.city,
createdAt,
};
}
export function createSessions(websiteId: string, day: Date, count: number): SessionData[] {
const sessions: SessionData[] = [];
for (let i = 0; i < count; i++) {
sessions.push(createSession(websiteId, day));
}
// Sort by createdAt to maintain chronological order
sessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
return sessions;
}