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,80 @@
import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js';
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
const deviceWeights: WeightedOption<DeviceType>[] = [
{ value: 'desktop', weight: 0.55 },
{ value: 'mobile', weight: 0.4 },
{ value: 'tablet', weight: 0.05 },
];
const browsersByDevice: Record<DeviceType, WeightedOption<string>[]> = {
desktop: [
{ value: 'Chrome', weight: 0.65 },
{ value: 'Safari', weight: 0.12 },
{ value: 'Firefox', weight: 0.1 },
{ value: 'Edge', weight: 0.1 },
{ value: 'Opera', weight: 0.03 },
],
mobile: [
{ value: 'Chrome', weight: 0.55 },
{ value: 'Safari', weight: 0.35 },
{ value: 'Samsung', weight: 0.05 },
{ value: 'Firefox', weight: 0.03 },
{ value: 'Opera', weight: 0.02 },
],
tablet: [
{ value: 'Safari', weight: 0.6 },
{ value: 'Chrome', weight: 0.35 },
{ value: 'Firefox', weight: 0.05 },
],
};
const osByDevice: Record<DeviceType, WeightedOption<string>[]> = {
desktop: [
{ value: 'Windows 10', weight: 0.5 },
{ value: 'Mac OS', weight: 0.3 },
{ value: 'Linux', weight: 0.12 },
{ value: 'Chrome OS', weight: 0.05 },
{ value: 'Windows 11', weight: 0.03 },
],
mobile: [
{ value: 'iOS', weight: 0.45 },
{ value: 'Android', weight: 0.55 },
],
tablet: [
{ value: 'iOS', weight: 0.75 },
{ value: 'Android', weight: 0.25 },
],
};
const screensByDevice: Record<DeviceType, string[]> = {
desktop: [
'1920x1080',
'2560x1440',
'1366x768',
'1440x900',
'3840x2160',
'1536x864',
'1680x1050',
'2560x1080',
],
mobile: ['390x844', '414x896', '375x812', '360x800', '428x926', '393x873', '412x915', '360x780'],
tablet: ['1024x768', '768x1024', '834x1194', '820x1180', '810x1080', '800x1280'],
};
export interface DeviceInfo {
device: DeviceType;
browser: string;
os: string;
screen: string;
}
export function getRandomDevice(): DeviceInfo {
const device = weightedRandom(deviceWeights);
const browser = weightedRandom(browsersByDevice[device]);
const os = weightedRandom(osByDevice[device]);
const screen = pickRandom(screensByDevice[device]);
return { device, browser, os, screen };
}

View file

@ -0,0 +1,144 @@
import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js';
interface GeoLocation {
country: string;
region: string;
city: string;
}
const countryWeights: WeightedOption<string>[] = [
{ value: 'US', weight: 0.35 },
{ value: 'GB', weight: 0.08 },
{ value: 'DE', weight: 0.06 },
{ value: 'FR', weight: 0.05 },
{ value: 'CA', weight: 0.04 },
{ value: 'AU', weight: 0.03 },
{ value: 'IN', weight: 0.08 },
{ value: 'BR', weight: 0.04 },
{ value: 'JP', weight: 0.03 },
{ value: 'NL', weight: 0.02 },
{ value: 'ES', weight: 0.02 },
{ value: 'IT', weight: 0.02 },
{ value: 'PL', weight: 0.02 },
{ value: 'SE', weight: 0.01 },
{ value: 'MX', weight: 0.02 },
{ value: 'KR', weight: 0.02 },
{ value: 'SG', weight: 0.01 },
{ value: 'ID', weight: 0.02 },
{ value: 'PH', weight: 0.01 },
{ value: 'TH', weight: 0.01 },
{ value: 'VN', weight: 0.01 },
{ value: 'RU', weight: 0.02 },
{ value: 'UA', weight: 0.01 },
{ value: 'ZA', weight: 0.01 },
{ value: 'NG', weight: 0.01 },
];
const regionsByCountry: Record<string, { region: string; city: string }[]> = {
US: [
{ region: 'CA', city: 'San Francisco' },
{ region: 'CA', city: 'Los Angeles' },
{ region: 'NY', city: 'New York' },
{ region: 'TX', city: 'Austin' },
{ region: 'TX', city: 'Houston' },
{ region: 'WA', city: 'Seattle' },
{ region: 'IL', city: 'Chicago' },
{ region: 'MA', city: 'Boston' },
{ region: 'CO', city: 'Denver' },
{ region: 'GA', city: 'Atlanta' },
{ region: 'FL', city: 'Miami' },
{ region: 'PA', city: 'Philadelphia' },
],
GB: [
{ region: 'ENG', city: 'London' },
{ region: 'ENG', city: 'Manchester' },
{ region: 'ENG', city: 'Birmingham' },
{ region: 'SCT', city: 'Edinburgh' },
{ region: 'ENG', city: 'Bristol' },
],
DE: [
{ region: 'BE', city: 'Berlin' },
{ region: 'BY', city: 'Munich' },
{ region: 'HH', city: 'Hamburg' },
{ region: 'HE', city: 'Frankfurt' },
{ region: 'NW', city: 'Cologne' },
],
FR: [
{ region: 'IDF', city: 'Paris' },
{ region: 'ARA', city: 'Lyon' },
{ region: 'PAC', city: 'Marseille' },
{ region: 'OCC', city: 'Toulouse' },
],
CA: [
{ region: 'ON', city: 'Toronto' },
{ region: 'BC', city: 'Vancouver' },
{ region: 'QC', city: 'Montreal' },
{ region: 'AB', city: 'Calgary' },
],
AU: [
{ region: 'NSW', city: 'Sydney' },
{ region: 'VIC', city: 'Melbourne' },
{ region: 'QLD', city: 'Brisbane' },
{ region: 'WA', city: 'Perth' },
],
IN: [
{ region: 'MH', city: 'Mumbai' },
{ region: 'KA', city: 'Bangalore' },
{ region: 'DL', city: 'New Delhi' },
{ region: 'TN', city: 'Chennai' },
{ region: 'TG', city: 'Hyderabad' },
],
BR: [
{ region: 'SP', city: 'Sao Paulo' },
{ region: 'RJ', city: 'Rio de Janeiro' },
{ region: 'MG', city: 'Belo Horizonte' },
],
JP: [
{ region: '13', city: 'Tokyo' },
{ region: '27', city: 'Osaka' },
{ region: '23', city: 'Nagoya' },
],
NL: [
{ region: 'NH', city: 'Amsterdam' },
{ region: 'ZH', city: 'Rotterdam' },
{ region: 'ZH', city: 'The Hague' },
],
};
const defaultRegions = [{ region: '', city: '' }];
export function getRandomGeo(): GeoLocation {
const country = weightedRandom(countryWeights);
const regions = regionsByCountry[country] || defaultRegions;
const { region, city } = pickRandom(regions);
return { country, region, city };
}
const languages: WeightedOption<string>[] = [
{ value: 'en-US', weight: 0.4 },
{ value: 'en-GB', weight: 0.08 },
{ value: 'de-DE', weight: 0.06 },
{ value: 'fr-FR', weight: 0.05 },
{ value: 'es-ES', weight: 0.05 },
{ value: 'pt-BR', weight: 0.04 },
{ value: 'ja-JP', weight: 0.03 },
{ value: 'zh-CN', weight: 0.05 },
{ value: 'ko-KR', weight: 0.02 },
{ value: 'ru-RU', weight: 0.02 },
{ value: 'it-IT', weight: 0.02 },
{ value: 'nl-NL', weight: 0.02 },
{ value: 'pl-PL', weight: 0.02 },
{ value: 'hi-IN', weight: 0.04 },
{ value: 'ar-SA', weight: 0.02 },
{ value: 'tr-TR', weight: 0.02 },
{ value: 'vi-VN', weight: 0.01 },
{ value: 'th-TH', weight: 0.01 },
{ value: 'id-ID', weight: 0.02 },
{ value: 'sv-SE', weight: 0.01 },
{ value: 'da-DK', weight: 0.01 },
];
export function getRandomLanguage(): string {
return weightedRandom(languages);
}

View file

@ -0,0 +1,163 @@
import { weightedRandom, pickRandom, randomInt, type WeightedOption } from '../utils.js';
export type ReferrerType = 'direct' | 'organic' | 'social' | 'paid' | 'referral';
export interface ReferrerInfo {
type: ReferrerType;
domain: string | null;
path: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmContent: string | null;
utmTerm: string | null;
gclid: string | null;
fbclid: string | null;
}
const referrerTypeWeights: WeightedOption<ReferrerType>[] = [
{ value: 'direct', weight: 0.4 },
{ value: 'organic', weight: 0.25 },
{ value: 'social', weight: 0.15 },
{ value: 'paid', weight: 0.1 },
{ value: 'referral', weight: 0.1 },
];
const searchEngines = [
{ domain: 'google.com', path: '/search' },
{ domain: 'bing.com', path: '/search' },
{ domain: 'duckduckgo.com', path: '/' },
{ domain: 'yahoo.com', path: '/search' },
{ domain: 'baidu.com', path: '/s' },
];
const socialPlatforms = [
{ domain: 'twitter.com', path: null },
{ domain: 'x.com', path: null },
{ domain: 'linkedin.com', path: '/feed' },
{ domain: 'facebook.com', path: null },
{ domain: 'reddit.com', path: '/r/programming' },
{ domain: 'news.ycombinator.com', path: '/item' },
{ domain: 'threads.net', path: null },
{ domain: 'bsky.app', path: null },
];
const referralSites = [
{ domain: 'medium.com', path: '/@author/article' },
{ domain: 'dev.to', path: '/post' },
{ domain: 'hashnode.com', path: '/blog' },
{ domain: 'techcrunch.com', path: '/article' },
{ domain: 'producthunt.com', path: '/posts' },
{ domain: 'indiehackers.com', path: '/post' },
];
interface PaidCampaign {
source: string;
medium: string;
campaign: string;
useGclid?: boolean;
useFbclid?: boolean;
}
const paidCampaigns: PaidCampaign[] = [
{ source: 'google', medium: 'cpc', campaign: 'brand_search', useGclid: true },
{ source: 'google', medium: 'cpc', campaign: 'product_awareness', useGclid: true },
{ source: 'facebook', medium: 'paid_social', campaign: 'retargeting', useFbclid: true },
{ source: 'facebook', medium: 'paid_social', campaign: 'lookalike', useFbclid: true },
{ source: 'linkedin', medium: 'cpc', campaign: 'b2b_targeting' },
{ source: 'twitter', medium: 'paid_social', campaign: 'launch_promo' },
];
const organicCampaigns = [
{ source: 'newsletter', medium: 'email', campaign: 'weekly_digest' },
{ source: 'newsletter', medium: 'email', campaign: 'product_update' },
{ source: 'partner', medium: 'referral', campaign: 'integration_launch' },
];
function generateClickId(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function getRandomReferrer(): ReferrerInfo {
const type = weightedRandom(referrerTypeWeights);
const result: ReferrerInfo = {
type,
domain: null,
path: null,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
utmTerm: null,
gclid: null,
fbclid: null,
};
switch (type) {
case 'direct':
// No referrer data
break;
case 'organic': {
const engine = pickRandom(searchEngines);
result.domain = engine.domain;
result.path = engine.path;
break;
}
case 'social': {
const platform = pickRandom(socialPlatforms);
result.domain = platform.domain;
result.path = platform.path;
// Some social traffic has UTM params
if (Math.random() < 0.3) {
result.utmSource = platform.domain.replace('.com', '').replace('.net', '');
result.utmMedium = 'social';
}
break;
}
case 'paid': {
const campaign = pickRandom(paidCampaigns);
result.utmSource = campaign.source;
result.utmMedium = campaign.medium;
result.utmCampaign = campaign.campaign;
result.utmContent = `ad_${randomInt(1, 5)}`;
if (campaign.useGclid) {
result.gclid = generateClickId();
result.domain = 'google.com';
result.path = '/search';
} else if (campaign.useFbclid) {
result.fbclid = generateClickId();
result.domain = 'facebook.com';
result.path = null;
}
break;
}
case 'referral': {
// Mix of pure referrals and organic campaigns
if (Math.random() < 0.6) {
const site = pickRandom(referralSites);
result.domain = site.domain;
result.path = site.path;
} else {
const campaign = pickRandom(organicCampaigns);
result.utmSource = campaign.source;
result.utmMedium = campaign.medium;
result.utmCampaign = campaign.campaign;
}
break;
}
}
return result;
}

View file

@ -0,0 +1,69 @@
import { weightedRandom, randomInt, type WeightedOption } from '../utils.js';
const hourlyWeights: WeightedOption<number>[] = [
{ value: 0, weight: 0.02 },
{ value: 1, weight: 0.01 },
{ value: 2, weight: 0.01 },
{ value: 3, weight: 0.01 },
{ value: 4, weight: 0.01 },
{ value: 5, weight: 0.02 },
{ value: 6, weight: 0.03 },
{ value: 7, weight: 0.05 },
{ value: 8, weight: 0.07 },
{ value: 9, weight: 0.08 },
{ value: 10, weight: 0.09 },
{ value: 11, weight: 0.08 },
{ value: 12, weight: 0.07 },
{ value: 13, weight: 0.08 },
{ value: 14, weight: 0.09 },
{ value: 15, weight: 0.08 },
{ value: 16, weight: 0.07 },
{ value: 17, weight: 0.06 },
{ value: 18, weight: 0.05 },
{ value: 19, weight: 0.04 },
{ value: 20, weight: 0.03 },
{ value: 21, weight: 0.03 },
{ value: 22, weight: 0.02 },
{ value: 23, weight: 0.02 },
];
const dayOfWeekWeights: WeightedOption<number>[] = [
{ value: 0, weight: 0.08 }, // Sunday
{ value: 1, weight: 0.16 }, // Monday
{ value: 2, weight: 0.17 }, // Tuesday
{ value: 3, weight: 0.17 }, // Wednesday
{ value: 4, weight: 0.16 }, // Thursday
{ value: 5, weight: 0.15 }, // Friday
{ value: 6, weight: 0.11 }, // Saturday
];
export function getWeightedHour(): number {
return weightedRandom(hourlyWeights);
}
export function getDayOfWeekMultiplier(dayOfWeek: number): number {
const weight = dayOfWeekWeights.find(d => d.value === dayOfWeek)?.weight ?? 0.14;
return weight / 0.14; // Normalize around 1.0
}
export function generateTimestampForDay(day: Date): Date {
const hour = getWeightedHour();
const minute = randomInt(0, 59);
const second = randomInt(0, 59);
const millisecond = randomInt(0, 999);
const timestamp = new Date(day);
timestamp.setHours(hour, minute, second, millisecond);
return timestamp;
}
export function getSessionCountForDay(baseCount: number, day: Date): number {
const dayOfWeek = day.getDay();
const multiplier = getDayOfWeekMultiplier(dayOfWeek);
// Add some random variance (±20%)
const variance = 0.8 + Math.random() * 0.4;
return Math.round(baseCount * multiplier * variance);
}