umami/scripts/generate-test-data.js
Sajid Mehmood dc8db69a6f Add test data generation script for Umami analytics
- Create generate-test-data.js script for generating realistic demo data
- Support for small (3 days), medium (14 days), and full (30 days) scales
- Generates realistic user sessions, pageviews, custom events, and revenue
- Includes geographic diversity, device variety, and traffic source distributions
- Implements proper UTM campaign tracking and conversion funnels
- Uses direct Prisma writes for performance (~10k sessions in 5 minutes)
- Add npm script: 'generate-test-data' for easy execution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 14:51:50 -05:00

1227 lines
36 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable no-console */
import 'dotenv/config';
import { PrismaClient } from '../generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import crypto from 'crypto';
import { startOfMonth, startOfHour, subDays, addHours, addSeconds, startOfDay } from 'date-fns';
import readline from 'readline';
// ============================================================================
// Configuration
// ============================================================================
const SCALES = {
small: {
days: 3,
avgSessionsPerDay: 666, // ~2000 total events
description: '3 days, ~2,000 events (~2 min)',
},
medium: {
days: 14,
avgSessionsPerDay: 714, // ~10,000 total events
description: '14 days, ~10,000 events (~5 min)',
},
full: {
days: 30,
avgSessionsPerDay: 666, // ~50,000 total events
description: '30 days, ~50,000 events (~15-20 min)',
},
};
// Realistic distributions
const GEO_DISTRIBUTION = [
{ country: 'US', region: 'California', city: 'San Francisco', weight: 15 },
{ country: 'US', region: 'New York', city: 'New York', weight: 10 },
{ country: 'US', region: 'Texas', city: 'Austin', weight: 8 },
{ country: 'US', region: 'Washington', city: 'Seattle', weight: 7 },
{ country: 'US', region: 'Florida', city: 'Miami', weight: 5 },
{ country: 'GB', region: 'England', city: 'London', weight: 10 },
{ country: 'GB', region: 'England', city: 'Manchester', weight: 5 },
{ country: 'CA', region: 'Ontario', city: 'Toronto', weight: 6 },
{ country: 'CA', region: 'British Columbia', city: 'Vancouver', weight: 4 },
{ country: 'DE', region: 'Berlin', city: 'Berlin', weight: 5 },
{ country: 'DE', region: 'Bavaria', city: 'Munich', weight: 3 },
{ country: 'IN', region: 'Karnataka', city: 'Bangalore', weight: 4 },
{ country: 'IN', region: 'Maharashtra', city: 'Mumbai', weight: 2 },
{ country: 'FR', region: 'Ile-de-France', city: 'Paris', weight: 5 },
{ country: 'AU', region: 'New South Wales', city: 'Sydney', weight: 3 },
{ country: 'AU', region: 'Victoria', city: 'Melbourne', weight: 1 },
{ country: 'BR', region: 'Sao Paulo', city: 'Sao Paulo', weight: 2 },
{ country: 'JP', region: 'Tokyo', city: 'Tokyo', weight: 2 },
{ country: 'NL', region: 'North Holland', city: 'Amsterdam', weight: 2 },
{ country: 'ES', region: 'Madrid', city: 'Madrid', weight: 1 },
];
const BROWSERS = [
{ name: 'Chrome', weight: 50 },
{ name: 'Safari', weight: 25 },
{ name: 'Firefox', weight: 12 },
{ name: 'Edge', weight: 10 },
{ name: 'Opera', weight: 2 },
{ name: 'Brave', weight: 1 },
];
const OS_DISTRIBUTION = [
{ name: 'Windows', version: '10', weight: 30 },
{ name: 'Windows', version: '11', weight: 10 },
{ name: 'Mac OS', version: '14.0', weight: 15 },
{ name: 'Mac OS', version: '13.0', weight: 10 },
{ name: 'iOS', version: '17.0', weight: 10 },
{ name: 'iOS', version: '16.0', weight: 8 },
{ name: 'Android', version: '14', weight: 7 },
{ name: 'Android', version: '13', weight: 5 },
{ name: 'Linux', version: 'Ubuntu', weight: 4 },
{ name: 'Linux', version: 'Fedora', weight: 1 },
];
const DEVICES = [
{ type: 'desktop', screen: '1920x1080', weight: 35 },
{ type: 'desktop', screen: '1366x768', weight: 15 },
{ type: 'desktop', screen: '1440x900', weight: 10 },
{ type: 'desktop', screen: '2560x1440', weight: 5 },
{ type: 'mobile', screen: '375x667', weight: 10 },
{ type: 'mobile', screen: '414x896', weight: 10 },
{ type: 'mobile', screen: '390x844', weight: 8 },
{ type: 'tablet', screen: '768x1024', weight: 4 },
{ type: 'tablet', screen: '820x1180', weight: 3 },
];
const LANGUAGES = [
{ code: 'en-US', weight: 50 },
{ code: 'en-GB', weight: 15 },
{ code: 'en-CA', weight: 5 },
{ code: 'de-DE', weight: 8 },
{ code: 'fr-FR', weight: 5 },
{ code: 'es-ES', weight: 4 },
{ code: 'pt-BR', weight: 3 },
{ code: 'ja-JP', weight: 2 },
{ code: 'zh-CN', weight: 3 },
{ code: 'hi-IN', weight: 3 },
{ code: 'nl-NL', weight: 2 },
];
const PAGES = [
{ path: '/', title: 'Niteshift - Cloud Dev Environments', weight: 30, isEntry: true },
{ path: '/features', title: 'Features - Niteshift', weight: 15, isEntry: false },
{ path: '/pricing', title: 'Pricing - Niteshift', weight: 12, isEntry: true },
{ path: '/docs', title: 'Documentation - Niteshift', weight: 10, isEntry: true },
{
path: '/docs/getting-started',
title: 'Getting Started - Niteshift Docs',
weight: 6,
isEntry: false,
},
{
path: '/docs/api-reference',
title: 'API Reference - Niteshift Docs',
weight: 4,
isEntry: false,
},
{
path: '/docs/deployment',
title: 'Deployment Guide - Niteshift Docs',
weight: 3,
isEntry: false,
},
{ path: '/blog', title: 'Blog - Niteshift', weight: 8, isEntry: true },
{
path: '/blog/introducing-niteshift',
title: 'Introducing Niteshift - Niteshift Blog',
weight: 5,
isEntry: true,
},
{
path: '/blog/dev-environments-best-practices',
title: 'Dev Environment Best Practices - Niteshift Blog',
weight: 3,
isEntry: true,
},
{ path: '/about', title: 'About Us - Niteshift', weight: 3, isEntry: false },
{ path: '/contact', title: 'Contact - Niteshift', weight: 2, isEntry: false },
{ path: '/signup', title: 'Sign Up - Niteshift', weight: 6, isEntry: false },
{ path: '/dashboard', title: 'Dashboard - Niteshift', weight: 1, isEntry: false },
];
const REFERRERS = [
{ type: 'direct', domain: '', weight: 35 },
{ type: 'search', domain: 'google.com', weight: 25 },
{ type: 'search', domain: 'bing.com', weight: 3 },
{ type: 'search', domain: 'duckduckgo.com', weight: 2 },
{ type: 'social', domain: 'twitter.com', weight: 6 },
{ type: 'social', domain: 'linkedin.com', weight: 5 },
{ type: 'social', domain: 'reddit.com', weight: 2 },
{ type: 'social', domain: 'news.ycombinator.com', weight: 2 },
{ type: 'referral', domain: 'dev.to', weight: 3 },
{ type: 'referral', domain: 'medium.com', weight: 2 },
{ type: 'referral', domain: 'indiehackers.com', weight: 2 },
{ type: 'referral', domain: 'producthunt.com', weight: 2 },
];
const UTM_CAMPAIGNS = [
{
source: 'google',
medium: 'cpc',
campaign: 'dev_tools_2025',
content: ['ad_variant_1', 'ad_variant_2'],
term: ['cloud dev', 'dev environment', 'remote development'],
weight: 40,
},
{
source: 'producthunt',
medium: 'social',
campaign: 'ph_launch',
content: null,
term: null,
weight: 35,
},
{
source: 'newsletter',
medium: 'email',
campaign: 'feature_announcement',
content: null,
term: null,
weight: 25,
},
];
const CUSTOM_EVENTS = [
{ name: 'click_cta_hero', frequency: 0.4 }, // 40% of homepage visits
{ name: 'click_start_free_trial', frequency: 0.3 }, // 30% of pricing page visits
{ name: 'download_whitepaper', frequency: 0.15 },
{ name: 'play_demo_video', frequency: 0.2 },
{ name: 'submit_contact_form', frequency: 0.1 },
{ name: 'click_docs_search', frequency: 0.25 },
{ name: 'share_social', frequency: 0.08 },
];
// ============================================================================
// Utility Functions
// ============================================================================
function hash(...args) {
return crypto.createHash('sha512').update(args.join('')).digest('hex');
}
function generateSessionId(websiteId, ip, userAgent, createdAt) {
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
return uuidv5(hash(websiteId, ip, userAgent, sessionSalt), uuidv5.DNS);
}
function generateVisitId(websiteId, sessionId, createdAt) {
const visitSalt = hash(startOfHour(createdAt).toUTCString());
return uuidv5(hash(websiteId, sessionId, visitSalt), uuidv5.DNS);
}
function weightedRandom(items) {
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
for (const item of items) {
random -= item.weight;
if (random <= 0) {
return item;
}
}
return items[items.length - 1];
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomIP() {
return `${randomInt(1, 255)}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(0, 255)}`;
}
function generateUserAgent(browser, os, device) {
const deviceString = device === 'mobile' ? 'Mobile' : device === 'tablet' ? 'Tablet' : '';
return `Mozilla/5.0 (${os}${deviceString ? '; ' + deviceString : ''}) AppleWebKit/537.36 (KHTML, like Gecko) ${browser} Safari/537.36`;
}
function getHourlyMultiplier(hour) {
// Traffic pattern: low 0-9, ramp 9-12, peak 12-20, decline 20-24 (UTC)
if (hour >= 0 && hour < 9) return 0.3;
if (hour >= 9 && hour < 12) return 0.8;
if (hour >= 12 && hour < 20) return 1.4;
return 0.6;
}
function getWeekdayMultiplier(dayOfWeek) {
// 0 = Sunday, 6 = Saturday
if (dayOfWeek === 0 || dayOfWeek === 6) return 0.5; // Weekend
if (dayOfWeek === 5) return 0.8; // Friday
return 1.0; // Monday-Thursday
}
function getWeekMultiplier(weekNum, totalWeeks) {
// Week 1: 90%, Week 2-3: 100%, Week 4+: 110%
if (weekNum === 0) return 0.9;
if (weekNum >= totalWeeks - 1) return 1.1;
return 1.0;
}
function shouldBounce(pagePath) {
// Blog posts have higher bounce rate
if (pagePath.startsWith('/blog/')) return Math.random() < 0.65;
if (pagePath === '/') return Math.random() < 0.45;
if (pagePath === '/pricing') return Math.random() < 0.35;
return Math.random() < 0.48;
}
async function promptUser(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => {
rl.question(question, answer => {
rl.close();
resolve(answer.toLowerCase());
});
});
}
// ============================================================================
// Data Generation
// ============================================================================
function generateSession(websiteId, timestamp) {
const geo = weightedRandom(GEO_DISTRIBUTION);
const browser = weightedRandom(BROWSERS);
const os = weightedRandom(OS_DISTRIBUTION);
const device = weightedRandom(DEVICES);
const language = weightedRandom(LANGUAGES);
const ip = randomIP();
const userAgent = generateUserAgent(browser.name, os.name, device.type);
const sessionId = generateSessionId(websiteId, ip, userAgent, timestamp);
return {
id: sessionId,
websiteId,
browser: browser.name,
os: `${os.name} ${os.version}`,
device: device.type,
screen: device.screen,
language: language.code,
country: geo.country,
region: geo.region,
city: geo.city,
createdAt: timestamp,
};
}
function generateUserJourney(session, websiteId, startTime) {
const events = [];
const visitId = generateVisitId(websiteId, session.id, startTime);
// Determine entry page
const entryPages = PAGES.filter(p => p.isEntry);
let currentPage = weightedRandom(entryPages);
// Determine referrer
const referrer = weightedRandom(REFERRERS);
let referrerDomain = referrer.domain;
let referrerPath = '';
let urlQuery = '';
// 8% of traffic has UTM parameters
const hasUTM = Math.random() < 0.08;
if (hasUTM) {
const campaign = weightedRandom(UTM_CAMPAIGNS);
const params = [
`utm_source=${campaign.source}`,
`utm_medium=${campaign.medium}`,
`utm_campaign=${campaign.campaign}`,
];
if (campaign.content) {
const content = campaign.content[randomInt(0, campaign.content.length - 1)];
params.push(`utm_content=${content}`);
}
if (campaign.term) {
const term = campaign.term[randomInt(0, campaign.term.length - 1)];
params.push(`utm_term=${encodeURIComponent(term)}`);
}
urlQuery = params.join('&');
referrerDomain = 'google.com'; // Most UTM traffic from paid search
} else if (referrer.type === 'search') {
referrerPath = '/search';
urlQuery =
'q=' +
encodeURIComponent(
['cloud dev', 'dev environment', 'remote development', 'niteshift'][randomInt(0, 3)],
);
} else if (referrer.type === 'social') {
referrerPath = referrer.domain === 'news.ycombinator.com' ? '/item?id=123456' : '/posts/123';
}
let currentTime = new Date(startTime);
// Parse UTM parameters if present
let utmParams = {};
if (urlQuery && urlQuery.includes('utm_')) {
const params = new URLSearchParams(urlQuery);
utmParams = {
utmSource: params.get('utm_source'),
utmMedium: params.get('utm_medium'),
utmCampaign: params.get('utm_campaign'),
utmContent: params.get('utm_content'),
utmTerm: params.get('utm_term'),
};
}
// First pageview (entry)
events.push({
id: uuidv4(),
websiteId,
sessionId: session.id,
visitId,
urlPath: currentPage.path,
urlQuery: urlQuery || null,
referrerDomain: referrerDomain || null,
referrerPath: referrerPath || null,
pageTitle: currentPage.title,
eventType: 1, // pageview
eventName: null,
hostname: 'niteshift.dev',
...utmParams,
createdAt: currentTime,
});
// Check if bounce
if (shouldBounce(currentPage.path)) {
return events; // Single page visit
}
// Generate custom event for entry page
const entryEvent = CUSTOM_EVENTS.find(e => {
if (currentPage.path === '/' && e.name === 'click_cta_hero') return true;
if (currentPage.path === '/pricing' && e.name === 'click_start_free_trial') return true;
return false;
});
if (entryEvent && Math.random() < entryEvent.frequency) {
currentTime = addSeconds(currentTime, randomInt(60, 180)); // 1-3 minutes later
events.push({
id: uuidv4(),
websiteId,
sessionId: session.id,
visitId,
urlPath: currentPage.path,
urlQuery: null,
referrerDomain: null,
referrerPath: null,
pageTitle: null,
eventType: 2, // custom event
eventName: entryEvent.name,
hostname: 'niteshift.dev',
createdAt: currentTime,
});
}
// Continue journey (2-5 more pages)
const additionalPages = randomInt(1, 4);
for (let i = 0; i < additionalPages; i++) {
currentTime = addSeconds(currentTime, randomInt(30, 180)); // 30s - 3min between pages
// Simple funnel logic
if (currentPage.path === '/') {
currentPage =
Math.random() < 0.5 ? PAGES.find(p => p.path === '/features') : weightedRandom(PAGES);
} else if (currentPage.path === '/features') {
currentPage =
Math.random() < 0.4 ? PAGES.find(p => p.path === '/pricing') : weightedRandom(PAGES);
} else if (currentPage.path === '/pricing') {
currentPage =
Math.random() < 0.3 ? PAGES.find(p => p.path === '/signup') : weightedRandom(PAGES);
} else if (currentPage.path === '/signup') {
// 60% convert to dashboard
if (Math.random() < 0.6) {
currentPage = PAGES.find(p => p.path === '/dashboard');
} else {
break; // Exit funnel
}
} else if (currentPage.path === '/dashboard') {
// End of conversion funnel
break;
} else {
currentPage = weightedRandom(PAGES);
}
events.push({
id: uuidv4(),
websiteId,
sessionId: session.id,
visitId,
urlPath: currentPage.path,
urlQuery: null,
referrerDomain: null,
referrerPath: null,
pageTitle: currentPage.title,
eventType: 1,
eventName: null,
hostname: 'niteshift.dev',
createdAt: currentTime,
});
// Chance for custom events on this page
const pageEvents = CUSTOM_EVENTS.filter(e => Math.random() < e.frequency / 3);
for (const evt of pageEvents) {
currentTime = addSeconds(currentTime, randomInt(10, 60));
events.push({
id: uuidv4(),
websiteId,
sessionId: session.id,
visitId,
urlPath: currentPage.path,
urlQuery: null,
referrerDomain: null,
referrerPath: null,
pageTitle: null,
eventType: 2,
eventName: evt.name,
hostname: 'niteshift.dev',
createdAt: currentTime,
});
}
}
return events;
}
function generateEventData(event) {
if (event.eventType !== 2 || !event.eventName) return null;
const data = [];
switch (event.eventName) {
case 'click_cta_hero':
data.push(
{
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'button_text',
stringValue: 'Start Free Trial',
dataType: 1,
},
{
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'position',
stringValue: 'above_fold',
dataType: 1,
},
);
break;
case 'click_start_free_trial':
data.push({
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'button_text',
stringValue: 'Start Trial',
dataType: 1,
});
break;
case 'download_whitepaper':
data.push({
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'document',
stringValue: 'dev-tools-guide-2025.pdf',
dataType: 1,
});
break;
case 'play_demo_video':
data.push(
{
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'video_id',
stringValue: 'intro_v2',
dataType: 1,
},
{
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'duration_watched',
numberValue: randomInt(15, 120),
dataType: 2,
},
);
break;
case 'submit_contact_form':
data.push({
id: uuidv4(),
websiteId: event.websiteId,
websiteEventId: event.id,
dataKey: 'form_type',
stringValue: 'contact',
dataType: 1,
});
break;
}
return data.length > 0 ? data : null;
}
function generateRevenue(event, session) {
// Only generate revenue for dashboard conversions
if (event.urlPath !== '/dashboard') return null;
// Revenue tiers
const tiers = [
{ revenue: 29, currency: 'USD', weight: 60 },
{ revenue: 79, currency: 'USD', weight: 30 },
{ revenue: 199, currency: 'USD', weight: 10 },
];
// Different currencies based on country
const currencyMap = { GB: 'GBP', DE: 'EUR', FR: 'EUR', ES: 'EUR' };
const baseCurrency = currencyMap[session.country] || 'USD';
const tier = weightedRandom(tiers);
const revenueAmount = tier.revenue;
const currency = baseCurrency;
return {
id: uuidv4(),
websiteId: event.websiteId,
sessionId: event.sessionId,
eventId: event.id,
eventName: 'subscription_created',
currency,
revenue: revenueAmount,
createdAt: event.createdAt,
};
}
// ============================================================================
// Database Operations
// ============================================================================
async function setupPrisma() {
const url = new URL(process.env.DATABASE_URL);
const adapter = new PrismaPg(
{ connectionString: url.toString() },
{ schema: url.searchParams.get('schema') },
);
return new PrismaClient({ adapter });
}
async function getWebsite(prisma, domain) {
const website = await prisma.website.findFirst({
where: { domain },
});
if (!website) {
throw new Error(`Website with domain "${domain}" not found. Please create it first.`);
}
return website;
}
async function cleanExistingData(prisma, websiteId) {
console.log('\n🗑 Cleaning existing data for website...');
// Delete in reverse dependency order
await prisma.revenue.deleteMany({ where: { websiteId } });
await prisma.eventData.deleteMany({ where: { websiteEvent: { websiteId } } });
await prisma.websiteEvent.deleteMany({ where: { websiteId } });
await prisma.sessionData.deleteMany({ where: { session: { websiteId } } });
await prisma.session.deleteMany({ where: { websiteId } });
await prisma.report.deleteMany({ where: { websiteId } });
await prisma.segment.deleteMany({ where: { websiteId } });
console.log('✓ Existing data cleaned');
}
async function insertData(prisma, sessions, events, eventDataList, revenueList) {
console.log('\n📝 Inserting data into database...');
// Insert sessions in batches
const sessionBatchSize = 1000;
for (let i = 0; i < sessions.length; i += sessionBatchSize) {
const batch = sessions.slice(i, i + sessionBatchSize);
await prisma.session.createMany({
data: batch,
skipDuplicates: true,
});
process.stdout.write(
`\r Sessions: ${Math.min(i + sessionBatchSize, sessions.length)}/${sessions.length}`,
);
}
console.log(' ✓');
// Insert events in batches
const eventBatchSize = 2000;
for (let i = 0; i < events.length; i += eventBatchSize) {
const batch = events.slice(i, i + eventBatchSize);
await prisma.websiteEvent.createMany({
data: batch,
skipDuplicates: true,
});
process.stdout.write(
`\r Events: ${Math.min(i + eventBatchSize, events.length)}/${events.length}`,
);
}
console.log(' ✓');
// Insert event data
if (eventDataList.length > 0) {
const dataBatchSize = 2000;
for (let i = 0; i < eventDataList.length; i += dataBatchSize) {
const batch = eventDataList.slice(i, i + dataBatchSize);
await prisma.eventData.createMany({
data: batch,
skipDuplicates: true,
});
process.stdout.write(
`\r Event Data: ${Math.min(i + dataBatchSize, eventDataList.length)}/${eventDataList.length}`,
);
}
console.log(' ✓');
}
// Insert revenue
if (revenueList.length > 0) {
await prisma.revenue.createMany({
data: revenueList,
skipDuplicates: true,
});
console.log(` Revenue: ${revenueList.length}/${revenueList.length}`);
}
}
async function createDemoReportsAndSegments(prisma, websiteId, userId) {
console.log('\n📊 Creating demo reports and segments...');
const now = new Date();
// Helper to get date 30 days ago
const thirtyDaysAgo = subDays(now, 30);
// ============================================================================
// FUNNELS
// ============================================================================
const funnels = [
{
id: uuidv4(),
userId,
websiteId,
type: 'funnel',
name: 'Signup Conversion Funnel',
description: 'Track users from homepage to signup completion',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
window: 30, // 30 minutes to complete
steps: [
{ type: 'path', value: '/' },
{ type: 'path', value: '/features' },
{ type: 'path', value: '/pricing' },
{ type: 'path', value: '/signup' },
{ type: 'path', value: '/dashboard' },
],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'funnel',
name: 'Documentation Journey',
description: 'How users navigate through documentation',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
window: 60, // 60 minutes to complete
steps: [
{ type: 'path', value: '/docs' },
{ type: 'path', value: '/docs/getting-started' },
{ type: 'path', value: '/docs/api-reference' },
],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'funnel',
name: 'Blog Engagement Flow',
description: 'From blog discovery to documentation',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
window: 45,
steps: [
{ type: 'path', value: '/blog' },
{ type: 'path', value: '/blog/*' }, // Any blog post
{ type: 'path', value: '/docs' },
],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'funnel',
name: 'CTA Click to Conversion',
description: 'Track CTA effectiveness',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
window: 20,
steps: [
{ type: 'event', value: 'click_cta_hero' },
{ type: 'path', value: '/pricing' },
{ type: 'path', value: '/signup' },
],
},
createdAt: now,
updatedAt: now,
},
];
// ============================================================================
// SEGMENTS
// ============================================================================
const segments = [
{
id: uuidv4(),
websiteId,
type: 'segment',
name: 'US Mobile Users',
parameters: {
filters: [
{ name: 'country', operator: 'eq', value: 'US' },
{ name: 'device', operator: 'eq', value: 'mobile' },
],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'segment',
name: 'Chrome Desktop Users',
parameters: {
filters: [
{ name: 'browser', operator: 'eq', value: 'Chrome' },
{ name: 'device', operator: 'eq', value: 'desktop' },
],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'segment',
name: 'Blog Readers',
parameters: {
filters: [{ name: 'path', operator: 'c', value: '/blog/' }],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'segment',
name: 'European Visitors',
parameters: {
filters: [
{
name: 'country',
operator: 'eq',
value: 'GB,DE,FR,ES,NL',
},
],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'segment',
name: 'Documentation Users',
parameters: {
filters: [{ name: 'path', operator: 'c', value: '/docs' }],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'segment',
name: 'High-Res Screens',
parameters: {
filters: [
{
name: 'screen',
operator: 'eq',
value: '1920x1080,2560x1440',
},
],
},
createdAt: now,
updatedAt: now,
},
];
// ============================================================================
// COHORTS
// ============================================================================
const cohorts = [
{
id: uuidv4(),
websiteId,
type: 'cohort',
name: 'January Signups',
parameters: {
dateRange: {
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
},
action: {
type: 'path',
value: '/dashboard',
},
filters: [],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'cohort',
name: 'Product Hunt Traffic',
parameters: {
dateRange: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
},
action: {
type: 'path',
value: '/',
},
filters: [{ name: 'referrer', operator: 'c', value: 'producthunt' }],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'cohort',
name: 'Google Ads Converters',
parameters: {
dateRange: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
},
action: {
type: 'path',
value: '/dashboard',
},
filters: [{ name: 'query', operator: 'c', value: 'utm_source=google' }],
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
websiteId,
type: 'cohort',
name: 'Blog Engaged Users',
parameters: {
dateRange: {
startDate: subDays(now, 14).toISOString(), // Last 2 weeks
endDate: now.toISOString(),
},
action: {
type: 'event',
value: 'click_cta_hero',
},
filters: [{ name: 'path', operator: 'c', value: '/blog' }],
},
createdAt: now,
updatedAt: now,
},
];
// ============================================================================
// OTHER USEFUL REPORTS
// ============================================================================
const otherReports = [
{
id: uuidv4(),
userId,
websiteId,
type: 'retention',
name: 'User Retention Analysis',
description: 'Track returning visitors over 30 days',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'journey',
name: 'Top User Journeys',
description: 'Most common navigation paths (5 steps)',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
steps: 5,
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'utm',
name: 'UTM Campaign Performance',
description: 'All UTM campaign metrics',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'revenue',
name: 'Revenue by Country',
description: 'Revenue breakdown by geographic location',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
currency: 'USD',
},
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
userId,
websiteId,
type: 'goal',
name: 'Video Play Goal',
description: 'Track video engagement',
parameters: {
startDate: thirtyDaysAgo.toISOString(),
endDate: now.toISOString(),
type: 'event',
value: 'play_demo_video',
},
createdAt: now,
updatedAt: now,
},
];
// Insert all reports
const allReports = [...funnels, ...otherReports];
await prisma.report.createMany({
data: allReports,
skipDuplicates: true,
});
console.log(` Reports: ${allReports.length}/${allReports.length}`);
// Insert all segments and cohorts
const allSegments = [...segments, ...cohorts];
await prisma.segment.createMany({
data: allSegments,
skipDuplicates: true,
});
console.log(` Segments & Cohorts: ${allSegments.length}/${allSegments.length}`);
}
// ============================================================================
// Main Function
// ============================================================================
async function main() {
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' Umami Test Data Generator');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
// Parse CLI arguments
const args = process.argv.slice(2);
const scaleArg = args.find(arg => arg.startsWith('--scale='))?.split('=')[1];
const cleanArg = args.includes('--clean');
const appendArg = args.includes('--append');
const scale = SCALES[scaleArg] || SCALES.small;
console.log(`📊 Scale: ${scaleArg || 'small'} (${scale.description})\n`);
// Setup Prisma
const prisma = await setupPrisma();
try {
// Get website
console.log('🔍 Looking up website...');
const website = await getWebsite(prisma, 'niteshift.dev');
console.log(`✓ Found website: ${website.name} (${website.id})\n`);
// Get admin user for reports
const adminUser = await prisma.user.findFirst({
where: { username: 'admin' },
});
if (!adminUser) {
throw new Error('Admin user not found. Please ensure Umami is properly set up.');
}
// Prompt for clean/append
let shouldClean = cleanArg;
if (!cleanArg && !appendArg) {
const answer = await promptUser(
'❓ Clean existing data or append? (clean/append) [append]: ',
);
shouldClean = answer === 'clean';
}
if (shouldClean) {
await cleanExistingData(prisma, website.id);
} else {
console.log(' Appending to existing data\n');
}
// Generate data
console.log('🎲 Generating test data...');
const startDate = subDays(new Date(), scale.days);
const allSessions = [];
const allEvents = [];
const allEventData = [];
const allRevenue = [];
let totalSessionsGenerated = 0;
const totalDays = scale.days;
for (let day = 0; day < scale.days; day++) {
const currentDay = addHours(startOfDay(startDate), day * 24);
const dayOfWeek = currentDay.getDay();
const weekNum = Math.floor(day / 7);
const weekMultiplier = getWeekMultiplier(weekNum, Math.ceil(totalDays / 7));
const dayMultiplier = getWeekdayMultiplier(dayOfWeek);
const sessionsToday = Math.round(scale.avgSessionsPerDay * weekMultiplier * dayMultiplier);
for (let sessionIdx = 0; sessionIdx < sessionsToday; sessionIdx++) {
// Distribute sessions across 24 hours with realistic pattern
const hour = randomInt(0, 23);
const minute = randomInt(0, 59);
const sessionTime = addHours(currentDay, hour + minute / 60);
const hourMultiplier = getHourlyMultiplier(hour);
if (Math.random() > hourMultiplier) continue; // Skip this session based on hour
const session = generateSession(website.id, sessionTime);
allSessions.push(session);
const events = generateUserJourney(session, website.id, sessionTime);
allEvents.push(...events);
// Generate event data for custom events
for (const event of events) {
const eventData = generateEventData(event);
if (eventData) {
allEventData.push(...eventData);
}
// Generate revenue for conversions
const revenue = generateRevenue(event, session);
if (revenue) {
allRevenue.push(revenue);
}
}
totalSessionsGenerated++;
}
process.stdout.write(
`\r Day ${day + 1}/${scale.days}: ${totalSessionsGenerated} sessions, ${allEvents.length} events`,
);
}
console.log(' ✓\n');
console.log('📈 Generated:');
console.log(` Sessions: ${allSessions.length}`);
console.log(` Events: ${allEvents.length}`);
console.log(` Event Data: ${allEventData.length}`);
console.log(` Revenue: ${allRevenue.length}`);
// Insert into database
await insertData(prisma, allSessions, allEvents, allEventData, allRevenue);
// Create demo reports and segments
await createDemoReportsAndSegments(prisma, website.id, adminUser.id);
// Summary
const totalRevenue = allRevenue.reduce((sum, r) => sum + Number(r.revenue), 0);
const conversionRate = ((allRevenue.length / allSessions.length) * 100).toFixed(2);
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✓ Test data generated successfully!\n');
console.log('📊 Summary:');
console.log(` Sessions: ${allSessions.length.toLocaleString()}`);
console.log(
` Pageviews: ${allEvents.filter(e => e.eventType === 1).length.toLocaleString()}`,
);
console.log(
` Custom Events: ${allEvents.filter(e => e.eventType === 2).length.toLocaleString()}`,
);
console.log(` Conversions: ${allRevenue.length}`);
console.log(` Conversion Rate: ${conversionRate}%`);
console.log(` Total Revenue: $${totalRevenue.toFixed(2)}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
} catch (error) {
console.error('\n❌ Error:', error.message);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();