chore:little fixes and formating and linting and patches

This commit is contained in:
2026-03-31 15:50:54 +02:00
parent a1ce71ffb6
commit 9b197abcfa
815 changed files with 22960 additions and 8982 deletions

View File

@@ -47,4 +47,4 @@
}
}
}
}
}

View File

@@ -24,7 +24,7 @@ async function main() {
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
const REDIS_URL = process.env.REDIS_URL;
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
if (!(DATABASE_URL && CLICKHOUSE_URL && REDIS_URL)) {
console.error('Environment variables are not set');
process.exit(1);
}

View File

@@ -40,7 +40,7 @@ export async function clearCache() {
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
@@ -94,7 +94,7 @@ export async function clearCache() {
console.log(chalk.yellow('\n📊 Projects:\n'));
for (const project of organization.projects) {
console.log(
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`
);
}
}
@@ -119,9 +119,11 @@ export async function clearCache() {
for (const project of organization.projects) {
// Clear project access cache for each member
for (const member of organization.members) {
if (!member.user?.id) continue;
if (!member.user?.id) {
continue;
}
console.log(
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`
);
await getProjectAccess.clear({
userId: member.user?.id,
@@ -141,8 +143,8 @@ export async function clearCache() {
console.log(chalk.gray(`Organization ID: ${organization.id}`));
console.log(
chalk.gray(
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
),
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`
)
);
// Example of what you might do:

View File

@@ -21,8 +21,8 @@ export async function deleteOrganization() {
console.log(chalk.red('\n🗑 Delete Organization\n'));
console.log(
chalk.yellow(
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
),
'⚠️ WARNING: This will permanently delete the organization and all its data!\n'
)
);
console.log('Loading organizations...\n');
@@ -51,7 +51,7 @@ export async function deleteOrganization() {
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
@@ -107,7 +107,7 @@ export async function deleteOrganization() {
console.log(chalk.red('\n Projects that will be deleted:'));
for (const project of organization.projects) {
console.log(
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`
);
}
}
@@ -122,8 +122,8 @@ export async function deleteOrganization() {
console.log(
chalk.red(
'\n⚠ This will delete ALL projects, clients, events, and data associated with this organization!',
),
'\n⚠ This will delete ALL projects, clients, events, and data associated with this organization!'
)
);
// First confirmation
@@ -132,7 +132,7 @@ export async function deleteOrganization() {
type: 'confirm',
name: 'confirmFirst',
message: chalk.red(
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`
),
default: false,
},
@@ -154,7 +154,7 @@ export async function deleteOrganization() {
if (confirmName !== organization.name) {
console.log(
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
chalk.red('\n❌ Organization name does not match. Deletion cancelled.')
);
return;
}
@@ -165,7 +165,7 @@ export async function deleteOrganization() {
type: 'confirm',
name: 'confirmFinal',
message: chalk.red(
'FINAL WARNING: This action CANNOT be undone. Delete now?',
'FINAL WARNING: This action CANNOT be undone. Delete now?'
),
default: false,
},
@@ -185,8 +185,8 @@ export async function deleteOrganization() {
if (projectIds.length > 0) {
console.log(
chalk.yellow(
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
),
`Deleting data from ClickHouse for ${projectIds.length} projects...`
)
);
await deleteFromClickhouse(projectIds);
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
@@ -200,13 +200,13 @@ export async function deleteOrganization() {
console.log(chalk.green('\n✅ Organization deleted successfully!'));
console.log(
chalk.gray(
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
),
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`
)
);
console.log(
chalk.gray(
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
),
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.'
)
);
} catch (error) {
console.error(chalk.red('\n❌ Error deleting organization:'), error);

View File

@@ -19,8 +19,8 @@ export async function deleteUser() {
console.log(chalk.red('\n🗑 Delete User\n'));
console.log(
chalk.yellow(
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
),
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n'
)
);
console.log('Loading users...\n');
@@ -59,7 +59,7 @@ export async function deleteUser() {
};
});
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: UserSearchItem) =>
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
@@ -107,46 +107,46 @@ export async function deleteUser() {
console.log(` ${chalk.bold('User:')} ${user.email}`);
if (user.firstName || user.lastName) {
console.log(
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`
);
}
console.log(` ${chalk.gray('ID:')} ${user.id}`);
console.log(
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`
);
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
if (user.createdOrganizations.length > 0) {
console.log(
chalk.red(
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
),
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`
)
);
for (const org of user.createdOrganizations) {
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
}
console.log(
chalk.yellow(
' Note: These organizations will NOT be deleted, only the user reference.',
),
' Note: These organizations will NOT be deleted, only the user reference.'
)
);
}
if (user.membership.length > 0) {
console.log(
chalk.red('\n Organizations where user will be removed from:'),
chalk.red('\n Organizations where user will be removed from:')
);
for (const member of user.membership) {
console.log(
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`
);
}
}
console.log(
chalk.red(
'\n⚠ This will delete the user account, all sessions, and remove them from all organizations!',
),
'\n⚠ This will delete the user account, all sessions, and remove them from all organizations!'
)
);
// First confirmation
@@ -155,7 +155,7 @@ export async function deleteUser() {
type: 'confirm',
name: 'confirmFirst',
message: chalk.red(
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`
),
default: false,
},
@@ -186,7 +186,7 @@ export async function deleteUser() {
type: 'confirm',
name: 'confirmFinal',
message: chalk.red(
'FINAL WARNING: This action CANNOT be undone. Delete now?',
'FINAL WARNING: This action CANNOT be undone. Delete now?'
),
default: false,
},
@@ -210,8 +210,8 @@ export async function deleteUser() {
console.log(chalk.green('\n✅ User deleted successfully!'));
console.log(
chalk.gray(
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
),
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`
)
);
} catch (error) {
console.error(chalk.red('\n❌ Error deleting user:'), error);

View File

@@ -47,7 +47,7 @@ export async function lookupByClient() {
displayText: `${client.organization.name}${client.project?.name || '[No Project]'}${client.name} ${chalk.gray(`(${client.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: ClientSearchItem) =>
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
@@ -101,4 +101,3 @@ export async function lookupByClient() {
highlightClientId: selectedClient.id,
});
}

View File

@@ -52,7 +52,7 @@ export async function lookupByEmail() {
};
});
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: EmailSearchItem) =>
`${item.email} ${item.organizationName}`,
@@ -103,10 +103,9 @@ export async function lookupByEmail() {
console.log(
chalk.yellow(
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
),
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`
)
);
displayOrganizationDetails(organization);
}

View File

@@ -35,7 +35,7 @@ export async function lookupByOrg() {
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
@@ -85,4 +85,3 @@ export async function lookupByOrg() {
displayOrganizationDetails(organization);
}

View File

@@ -42,7 +42,7 @@ export async function lookupByProject() {
displayText: `${project.organization.name}${project.name} ${chalk.gray(`(${project.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const searchFunction = (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: ProjectSearchItem) =>
`${item.organizationName} ${item.name} ${item.id}`,
@@ -95,4 +95,3 @@ export async function lookupByProject() {
highlightProjectId: selectedProject.id,
});
}

View File

@@ -23,7 +23,7 @@ interface DisplayOptions {
export function displayOrganizationDetails(
organization: OrganizationWithDetails,
options: DisplayOptions = {},
options: DisplayOptions = {}
) {
console.log(`\n${'='.repeat(80)}`);
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
@@ -34,18 +34,18 @@ export function displayOrganizationDetails(
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
console.log(
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`
);
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
// Subscription info
if (organization.subscriptionStatus) {
console.log(
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`
);
if (organization.subscriptionPriceId) {
console.log(
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`
);
}
if (organization.subscriptionPeriodEventsLimit) {
@@ -61,24 +61,24 @@ export function displayOrganizationDetails(
? chalk.yellow
: chalk.green;
console.log(
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`
);
}
if (organization.subscriptionStartsAt) {
console.log(
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`
);
}
if (organization.subscriptionEndsAt) {
console.log(
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`
);
}
}
if (organization.deleteAt) {
console.log(
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`
);
}
@@ -90,7 +90,7 @@ export function displayOrganizationDetails(
for (const member of organization.members) {
const roleBadge = getRoleBadge(member.role);
console.log(
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`
);
}
}
@@ -108,7 +108,7 @@ export function displayOrganizationDetails(
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
console.log(` ${chalk.gray('ID:')} ${project.id}`);
console.log(
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`
);
if (project.domain) {
@@ -120,15 +120,15 @@ export function displayOrganizationDetails(
}
console.log(
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`
);
console.log(
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`
);
if (project.deleteAt) {
console.log(
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`
);
}
@@ -146,10 +146,10 @@ export function displayOrganizationDetails(
console.log(` ${chalk.gray('ID:')} ${client.id}`);
console.log(` ${chalk.gray('Type:')} ${client.type}`);
console.log(
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`
);
console.log(
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`
);
}
} else {
@@ -159,7 +159,7 @@ export function displayOrganizationDetails(
}
// Clients without projects (organization-level clients)
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
const _orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
console.log(`\n${'='.repeat(80)}\n`);
}

View File

@@ -5,11 +5,8 @@
"rootDir": "src",
"target": "ES2022",
"lib": ["ES2022"],
"types": [
"node"
],
"types": ["node"],
"strictNullChecks": true
},
"include": ["src"]
}

View File

@@ -65,4 +65,4 @@
"tsdown": "0.14.2",
"typescript": "catalog:"
}
}
}

View File

@@ -1,14 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import yaml from 'js-yaml';
// Regex special characters that indicate we need actual regex
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
const regexSpecialChars = /[|^$.*+?(){}[\]\\]/;
function transformBots(bots: any[]): any[] {
return bots.map((bot) => {
@@ -28,7 +28,7 @@ async function main() {
// Get document, or throw exception on error
try {
const data = await fetch(
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml'
).then((res) => res.text());
const parsedData = yaml.load(data) as any[];
@@ -45,11 +45,11 @@ async function main() {
'export default bots;',
'',
].join('\n'),
'utf-8',
'utf-8'
);
console.log(
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
`✅ Generated bots.ts with ${transformedBots.length} bot entries`
);
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
const includesCount = transformedBots.filter((b) => 'includes' in b).length;

View File

@@ -133,7 +133,7 @@ function generateEvents(): Event[] {
clientId,
profile: profiles[i % PROFILE_COUNT]!,
eventsCount: Math.floor(Math.random() * 10),
}),
})
);
}
});
@@ -150,7 +150,7 @@ let lastTriggeredIndex = 0;
async function triggerEvents(generatedEvents: any[]) {
const EVENTS_PER_SECOND = Number.parseInt(
process.env.EVENTS_PER_SECOND || '100',
10,
10
);
const INTERVAL_MS = 1000 / EVENTS_PER_SECOND;
@@ -164,7 +164,7 @@ async function triggerEvents(generatedEvents: any[]) {
await trackit(event);
console.log(`Event ${lastTriggeredIndex + 1} sent successfully`);
console.log(
`sending ${event.track.payload?.properties?.__path} from user ${event.headers['user-agent']}`,
`sending ${event.track.payload?.properties?.__path} from user ${event.headers['user-agent']}`
);
} catch (error) {
console.error(`Failed to send event ${lastTriggeredIndex + 1}:`, error);
@@ -174,7 +174,7 @@ async function triggerEvents(generatedEvents: any[]) {
const remainingEvents = generatedEvents.length - lastTriggeredIndex;
console.log(
`Triggered ${lastTriggeredIndex} events. Remaining: ${remainingEvents}`,
`Triggered ${lastTriggeredIndex} events. Remaining: ${remainingEvents}`
);
if (remainingEvents > 0) {
@@ -215,7 +215,7 @@ async function createMock(file: string) {
fs.writeFileSync(
file,
JSON.stringify(insertFakeEvents(scrambleEvents(generateEvents())), null, 2),
'utf-8',
'utf-8'
);
}
@@ -438,7 +438,7 @@ async function simultaneousRequests() {
if (group.parallel && group.tracks.length > 1) {
// Parallel execution for same-flagged tracks
console.log(
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`,
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`
);
const promises = group.tracks.map(async (track) => {
const { name, parallel, ...properties } = track;

View File

@@ -14,7 +14,7 @@ const CLIENT_ID = process.env.CLIENT_ID!;
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
if (!CLIENT_ID || !CLIENT_SECRET) {
if (!(CLIENT_ID && CLIENT_SECRET)) {
console.error('CLIENT_ID and CLIENT_SECRET must be set');
process.exit(1);
}
@@ -34,7 +34,7 @@ const results: TestResult[] = [];
async function makeRequest(
method: string,
path: string,
body?: any,
body?: any
): Promise<TestResult> {
const url = `${API_BASE_URL}${path}`;
const headers: Record<string, string> = {
@@ -90,9 +90,11 @@ async function testProjects() {
});
results.push(createResult);
console.log(
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
if (createResult.error) {
console.log(` Error: ${createResult.error}`);
}
const projectId = createResult.data?.data?.id;
const clientId = createResult.data?.data?.client?.id;
@@ -100,15 +102,19 @@ async function testProjects() {
if (projectId) {
console.log(` Created project: ${projectId}`);
if (clientId) console.log(` Created client: ${clientId}`);
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
if (clientId) {
console.log(` Created client: ${clientId}`);
}
if (clientSecret) {
console.log(` Client secret: ${clientSecret}`);
}
}
// List projects
const listResult = await makeRequest('GET', '/manage/projects');
results.push(listResult);
console.log(
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} projects`);
@@ -119,7 +125,7 @@ async function testProjects() {
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
results.push(getResult);
console.log(
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
);
// Update project
@@ -129,21 +135,21 @@ async function testProjects() {
{
name: 'Updated Test Project',
crossDomain: true,
},
}
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
);
// Delete project (soft delete)
const deleteResult = await makeRequest(
'DELETE',
`/manage/projects/${projectId}`,
`/manage/projects/${projectId}`
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
);
}
@@ -161,26 +167,30 @@ async function testClients(projectId?: string) {
});
results.push(createResult);
console.log(
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
if (createResult.error) {
console.log(` Error: ${createResult.error}`);
}
const clientId = createResult.data?.data?.id;
const clientSecret = createResult.data?.data?.secret;
if (clientId) {
console.log(` Created client: ${clientId}`);
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
if (clientSecret) {
console.log(` Client secret: ${clientSecret}`);
}
}
// List clients
const listResult = await makeRequest(
'GET',
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients'
);
results.push(listResult);
console.log(
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} clients`);
@@ -191,7 +201,7 @@ async function testClients(projectId?: string) {
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
results.push(getResult);
console.log(
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
);
// Update client
@@ -200,21 +210,21 @@ async function testClients(projectId?: string) {
`/manage/clients/${clientId}`,
{
name: 'Updated Test Client',
},
}
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
);
// Delete client
const deleteResult = await makeRequest(
'DELETE',
`/manage/clients/${clientId}`,
`/manage/clients/${clientId}`
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
);
}
}
@@ -236,9 +246,11 @@ async function testReferences(projectId?: string) {
});
results.push(createResult);
console.log(
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
if (createResult.error) {
console.log(` Error: ${createResult.error}`);
}
const referenceId = createResult.data?.data?.id;
@@ -249,11 +261,11 @@ async function testReferences(projectId?: string) {
// List references
const listResult = await makeRequest(
'GET',
`/manage/references?projectId=${projectId}`,
`/manage/references?projectId=${projectId}`
);
results.push(listResult);
console.log(
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} references`);
@@ -263,11 +275,11 @@ async function testReferences(projectId?: string) {
// Get reference
const getResult = await makeRequest(
'GET',
`/manage/references/${referenceId}`,
`/manage/references/${referenceId}`
);
results.push(getResult);
console.log(
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
);
// Update reference
@@ -278,21 +290,21 @@ async function testReferences(projectId?: string) {
title: 'Updated Test Reference',
description: 'Updated description',
datetime: new Date().toISOString(),
},
}
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
);
// Delete reference
const deleteResult = await makeRequest(
'DELETE',
`/manage/references/${referenceId}`,
`/manage/references/${referenceId}`
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
);
}
}
@@ -328,7 +340,9 @@ async function main() {
.filter((r) => !r.success)
.forEach((r) => {
console.log(`${r.name} (${r.status})`);
if (r.error) console.log(` Error: ${r.error}`);
if (r.error) {
console.log(` Error: ${r.error}`);
}
});
}
} catch (error) {

View File

@@ -1,11 +1,10 @@
import { type IClickhouseEvent, ch, createEvent } from '@openpanel/db';
import { formatClickhouseDate } from '@openpanel/db';
import { ch, formatClickhouseDate, type IClickhouseEvent } from '@openpanel/db';
import { v4 as uuid } from 'uuid';
async function main() {
const startDate = new Date('2025-01-01T00:00:00Z');
const endDate = new Date();
const eventsPerDay = 25000;
const eventsPerDay = 25_000;
const variance = 3000;
// Event names to randomly choose from
@@ -36,7 +35,7 @@ async function main() {
device_id: `device_${Math.floor(Math.random() * 1000)}`,
profile_id: `profile_${Math.floor(Math.random() * 1000)}`,
project_id: 'testing',
session_id: `session_${Math.floor(Math.random() * 10000)}`,
session_id: `session_${Math.floor(Math.random() * 10_000)}`,
properties: {
hash: 'test-hash',
'query.utm_source': 'test',
@@ -75,7 +74,7 @@ async function main() {
// Log progress
console.log(
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`,
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`
);
}
}

View File

@@ -1,3 +1,7 @@
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import { getProjectAccess } from '@openpanel/trpc/src/access';
import { appendResponseMessages, type Message, streamText } from 'ai';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getChatModel, getChatSystemPrompt } from '@/utils/ai';
import {
getAllEventNames,
@@ -8,10 +12,6 @@ import {
getReport,
} from '@/utils/ai-tools';
import { HttpError } from '@/utils/errors';
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import { getProjectAccess } from '@openpanel/trpc/src/access';
import { type Message, appendResponseMessages, streamText } from 'ai';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function chat(
request: FastifyRequest<{
@@ -22,7 +22,7 @@ export async function chat(
messages: Message[];
};
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { session } = request.session;
const { messages } = request.body;
@@ -117,7 +117,7 @@ export async function chat(
},
});
},
onError: async (error) => {
onError: (error) => {
request.log.error('chat error', { error });
},
});

View File

@@ -1,18 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - OpenPanel</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - OpenPanel</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
rel="stylesheet"
>
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
font-family: "Inter", sans-serif;
display: flex;
justify-content: center;
align-items: center;
@@ -47,16 +49,21 @@
font-size: 1rem;
line-height: 1.5;
}
</style>
</head>
</style>
</head>
<body>
<div class="error-container">
<img src="https://openpanel.dev/logo.svg" alt="OpenPanel Logo" class="logo">
<h1>Oops! Something went wrong</h1>
<p>We encountered an error while processing your request. Please try again later or contact support if the problem
persists.</p>
</div>
</body>
</html>
<body>
<div class="error-container">
<img
src="https://openpanel.dev/logo.svg"
alt="OpenPanel Logo"
class="logo"
>
<h1>Oops! Something went wrong</h1>
<p>
We encountered an error while processing your request. Please try again
later or contact support if the problem persists.
</p>
</div>
</body>
</html>

View File

@@ -71,22 +71,22 @@ const eventsScheme = z.object({
page: z.coerce.number().optional().default(1),
limit: z.coerce.number().optional().default(50),
includes: z
.preprocess(
(arg) => {
if (arg == null) {
return undefined;
}
if (Array.isArray(arg)) {
return arg;
}
if (typeof arg === 'string') {
const parts = arg.split(',').map((s) => s.trim()).filter(Boolean);
return parts;
}
.preprocess((arg) => {
if (arg == null) {
return undefined;
}
if (Array.isArray(arg)) {
return arg;
},
z.array(z.string())
)
}
if (typeof arg === 'string') {
const parts = arg
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts;
}
return arg;
}, z.array(z.string()))
.optional(),
});

View File

@@ -1,14 +1,13 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { toDots } from '@openpanel/common';
import type { IClickhouseEvent } from '@openpanel/db';
import { TABLE_NAMES, ch, formatClickhouseDate } from '@openpanel/db';
import { ch, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function importEvents(
request: FastifyRequest<{
Body: IClickhouseEvent[];
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const projectId = request.client?.projectId;
if (!projectId) {

View File

@@ -1,4 +1,3 @@
import { parseQueryString } from '@/utils/parse-zod-query-string';
import { getDefaultIntervalByDates } from '@openpanel/constants';
import {
eventBuffer,
@@ -9,6 +8,7 @@ import {
import { zChartEventFilter, zRange } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { parseQueryString } from '@/utils/parse-zod-query-string';
const zGetMetricsQuery = z.object({
startDate: z.string().nullish(),
@@ -22,7 +22,7 @@ export async function getMetrics(
Params: { projectId: string };
Querystring: z.infer<typeof zGetMetricsQuery>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { timezone } = await getSettingsForProject(request.params.projectId);
const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query));
@@ -41,11 +41,11 @@ export async function getMetrics(
await overviewService.getMetrics({
projectId: request.params.projectId,
filters: parsed.data.filters,
startDate: startDate,
endDate: endDate,
startDate,
endDate,
interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day',
timezone,
}),
})
);
}
@@ -54,7 +54,7 @@ export async function getLiveVisitors(
request: FastifyRequest<{
Params: { projectId: string };
}>,
reply: FastifyReply,
reply: FastifyReply
) {
reply.send({
visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId),
@@ -76,7 +76,7 @@ export async function getPages(
Params: { projectId: string };
Querystring: z.infer<typeof zGetTopPagesQuery>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { timezone } = await getSettingsForProject(request.params.projectId);
const { startDate, endDate } = getChartStartEndDate(request.query, timezone);
@@ -93,8 +93,8 @@ export async function getPages(
return overviewService.getTopPages({
projectId: request.params.projectId,
filters: parsed.data.filters,
startDate: startDate,
endDate: endDate,
startDate,
endDate,
timezone,
});
}
@@ -132,19 +132,19 @@ const zGetOverviewGenericQuery = z.object({
});
export function getOverviewGeneric(
column: z.infer<typeof zGetOverviewGenericQuery>['column'],
column: z.infer<typeof zGetOverviewGenericQuery>['column']
) {
return async (
request: FastifyRequest<{
Params: { projectId: string; key: string };
Querystring: z.infer<typeof zGetOverviewGenericQuery>;
}>,
reply: FastifyReply,
reply: FastifyReply
) => {
const { timezone } = await getSettingsForProject(request.params.projectId);
const { startDate, endDate } = getChartStartEndDate(
request.query,
timezone,
timezone
);
const parsed = zGetOverviewGenericQuery.safeParse({
...parseQueryString(request.query),
@@ -165,10 +165,10 @@ export function getOverviewGeneric(
column,
projectId: request.params.projectId,
filters: parsed.data.filters,
startDate: startDate,
endDate: endDate,
startDate,
endDate,
timezone,
}),
})
);
};
}

View File

@@ -1,16 +1,15 @@
import crypto from 'node:crypto';
import { logger } from '@/utils/logger';
import { parseUrlMeta } from '@/utils/parseUrlMeta';
import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp';
import {
DEFAULT_IP_HEADER_ORDER,
getClientIpFromHeaders,
} from '@openpanel/common/server/get-client-ip';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { ch, chQuery, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getCache, getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp';
import { logger } from '@/utils/logger';
import { parseUrlMeta } from '@/utils/parseUrlMeta';
interface GetFaviconParams {
url: string;
@@ -29,7 +28,9 @@ function createCacheKey(url: string, prefix = 'favicon'): string {
function validateUrl(raw?: string): URL | null {
try {
if (!raw) throw new Error('Missing ?url');
if (!raw) {
throw new Error('Missing ?url');
}
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
@@ -42,7 +43,7 @@ function validateUrl(raw?: string): URL | null {
// Binary cache functions (more efficient than base64)
async function getFromCacheBinary(
key: string,
key: string
): Promise<{ buffer: Buffer; contentType: string } | null> {
const redis = getRedisCache();
const [bufferBase64, contentType] = await Promise.all([
@@ -50,14 +51,16 @@ async function getFromCacheBinary(
redis.get(`${key}:ctype`),
]);
if (!bufferBase64 || !contentType) return null;
if (!(bufferBase64 && contentType)) {
return null;
}
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
}
async function setToCacheBinary(
key: string,
buffer: Buffer,
contentType: string,
contentType: string
): Promise<void> {
const redis = getRedisCache();
await Promise.all([
@@ -68,7 +71,7 @@ async function setToCacheBinary(
// Fetch image with timeout and size limits
async function fetchImage(
url: URL,
url: URL
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
@@ -132,7 +135,7 @@ function isSvgFile(url: string, contentType?: string): boolean {
async function processImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
contentType?: string
): Promise<Buffer> {
// If it's an ICO file, just return it as-is (no conversion needed)
if (originalUrl && isIcoFile(originalUrl, contentType)) {
@@ -183,10 +186,10 @@ async function processImage(
async function processOgImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
contentType?: string
): Promise<Buffer> {
// If buffer is small enough, return it as-is
if (buffer.length < 10000) {
if (buffer.length < 10_000) {
logger.debug('Serving OG image directly without processing', {
originalUrl,
bufferSize: buffer.length,
@@ -227,7 +230,7 @@ export async function getFavicon(
request: FastifyRequest<{
Querystring: GetFaviconParams;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
logger.info('getFavicon', {
@@ -295,7 +298,7 @@ export async function getFavicon(
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
const { hostname } = url;
const duckduckgoUrl = new URL(
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
`https://icons.duckduckgo.com/ip3/${hostname}.ico`
);
logger.info('Trying DuckDuckGo favicon service', {
@@ -328,7 +331,7 @@ export async function getFavicon(
const processedBuffer = await processImage(
buffer,
imageUrl.toString(),
contentType,
contentType
);
logger.info('Favicon processing result', {
@@ -380,7 +383,7 @@ export async function getFavicon(
export async function clearFavicons(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const redis = getRedisCache();
const keys = await redis.keys('favicon:*');
@@ -396,7 +399,7 @@ export async function clearFavicons(
export async function clearOgImages(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const redis = getRedisCache();
const keys = await redis.keys('og:*');
@@ -417,7 +420,7 @@ export async function ping(
count: number;
};
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
await ch.insert({
@@ -449,10 +452,10 @@ export async function ping(
export async function stats(request: FastifyRequest, reply: FastifyReply) {
const res = await getCache('api:stats', 60 * 60, async () => {
const projects = await chQuery<{ project_id: string; count: number }>(
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`,
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`
);
const last24h = await chQuery<{ count: number }>(
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`
);
return { projects, last24hCount: last24h[0]?.count || 0 };
});
@@ -474,7 +477,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
ip,
geo: await getGeoLocation(ip),
};
}),
})
);
if (!ip) {
@@ -492,7 +495,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
acc[other.header] = other;
return acc;
},
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>
),
});
}
@@ -503,7 +506,7 @@ export async function getOgImage(
url: string;
};
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
const url = validateUrl(request.query.url);
@@ -547,7 +550,7 @@ export async function getOgImage(
const processedBuffer = await processOgImage(
buffer,
imageUrl.toString(),
contentType,
contentType
);
// Cache the result

View File

@@ -1,6 +1,3 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda';
import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
@@ -8,12 +5,14 @@ import type {
DeprecatedIncrementProfilePayload,
DeprecatedUpdateProfilePayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda';
export async function updateProfile(
request: FastifyRequest<{
Body: DeprecatedUpdateProfilePayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const payload = request.body;
const projectId = request.client!.projectId;
@@ -54,7 +53,7 @@ export async function incrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { profileId, property, value } = request.body;
const projectId = request.client!.projectId;
@@ -69,7 +68,7 @@ export async function incrementProfileProperty(
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
10
);
if (Number.isNaN(parsed)) {
@@ -79,7 +78,7 @@ export async function incrementProfileProperty(
profile.properties = assocPath(
property.split('.'),
parsed + value,
profile.properties,
profile.properties
);
await upsertProfile({
@@ -96,7 +95,7 @@ export async function decrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { profileId, property, value } = request.body;
const projectId = request.client?.projectId;
@@ -111,7 +110,7 @@ export async function decrementProfileProperty(
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
10
);
if (Number.isNaN(parsed)) {
@@ -121,7 +120,7 @@ export async function decrementProfileProperty(
profile.properties = assocPath(
property.split('.'),
parsed - value,
profile.properties,
profile.properties
);
await upsertProfile({

View File

@@ -1,18 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import {
sendSlackNotification,
slackInstaller,
} from '@openpanel/integrations/src/slack';
import {
PolarWebhookVerificationError,
getProduct,
PolarWebhookVerificationError,
validatePolarEvent,
} from '@openpanel/payments';
import { publishEvent } from '@openpanel/redis';
@@ -34,7 +34,7 @@ export async function slackWebhook(
request: FastifyRequest<{
Querystring: unknown;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsedParams = paramsSchema.safeParse(request.query);
@@ -45,10 +45,10 @@ export async function slackWebhook(
const veryfiedState = await slackInstaller.stateStore?.verifyStateParam(
new Date(),
parsedParams.data.state,
parsedParams.data.state
);
const parsedMetadata = metadataSchema.safeParse(
JSON.parse(veryfiedState?.metadata ?? '{}'),
JSON.parse(veryfiedState?.metadata ?? '{}')
);
if (!parsedMetadata.success) {
@@ -75,7 +75,7 @@ export async function slackWebhook(
zod: parsedJson,
json,
},
'Failed to parse slack auth response',
'Failed to parse slack auth response'
);
const html = fs.readFileSync(path.join(__dirname, 'error.html'), 'utf8');
return reply.status(500).header('Content-Type', 'text/html').send(html);
@@ -104,7 +104,7 @@ export async function slackWebhook(
});
return reply.redirect(
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`,
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`
);
} catch (err) {
request.log.error(err);
@@ -128,13 +128,13 @@ export async function polarWebhook(
request: FastifyRequest<{
Querystring: unknown;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
const event = validatePolarEvent(
request.rawBody!,
request.headers as Record<string, string>,
process.env.POLAR_WEBHOOK_SECRET ?? '',
process.env.POLAR_WEBHOOK_SECRET ?? ''
);
switch (event.type) {

View File

@@ -1,15 +1,15 @@
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
export async function clientHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
const client = await validateSdkRequest(req);

View File

@@ -1,8 +1,4 @@
import type {
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
import type { FastifyRequest } from 'fastify';
export async function requestIdHook(request: FastifyRequest) {
if (!request.headers['request-id']) {

View File

@@ -1,6 +1,6 @@
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import * as controller from '@/controllers/ai.controller';
import { activateRateLimiter } from '@/utils/rate-limiter';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const aiRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter<

View File

@@ -1,6 +1,5 @@
import * as controller from '@/controllers/event.controller';
import type { FastifyPluginCallback } from 'fastify';
import * as controller from '@/controllers/event.controller';
import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook';

View File

@@ -1,8 +1,8 @@
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import * as controller from '@/controllers/export.controller';
import { validateExportRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const exportRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({

View File

@@ -1,5 +1,5 @@
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({

View File

@@ -1,8 +1,7 @@
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import * as controller from '@/controllers/import.controller';
import { validateImportRequest } from '@/utils/auth';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import { Prisma } from '@openpanel/db';
const importRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {

View File

@@ -1,8 +1,8 @@
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import * as controller from '@/controllers/insights.controller';
import { validateExportRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const insightsRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({

View File

@@ -1,6 +1,6 @@
import * as controller from '@/controllers/live.controller';
import fastifyWS from '@fastify/websocket';
import type { FastifyPluginCallback } from 'fastify';
import * as controller from '@/controllers/live.controller';
const liveRouter: FastifyPluginCallback = async (fastify) => {
fastify.register(fastifyWS);
@@ -9,22 +9,22 @@ const liveRouter: FastifyPluginCallback = async (fastify) => {
fastify.get(
'/organization/:organizationId',
{ websocket: true },
controller.wsOrganizationEvents,
controller.wsOrganizationEvents
);
fastify.get(
'/visitors/:projectId',
{ websocket: true },
controller.wsVisitors,
controller.wsVisitors
);
fastify.get(
'/events/:projectId',
{ websocket: true },
controller.wsProjectEvents,
controller.wsProjectEvents
);
fastify.get(
'/notifications/:projectId',
{ websocket: true },
controller.wsProjectNotifications,
controller.wsProjectNotifications
);
});
};

View File

@@ -1,8 +1,8 @@
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import * as controller from '@/controllers/manage.controller';
import { validateManageRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const manageRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({

View File

@@ -1,5 +1,5 @@
import * as controller from '@/controllers/misc.controller';
import type { FastifyPluginCallback } from 'fastify';
import * as controller from '@/controllers/misc.controller';
const miscRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({

View File

@@ -1,5 +1,5 @@
import * as controller from '@/controllers/oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
import * as controller from '@/controllers/oauth-callback.controller';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({

View File

@@ -1,7 +1,7 @@
import type { FastifyPluginCallback } from 'fastify';
import * as controller from '@/controllers/profile.controller';
import { clientHook } from '@/hooks/client.hook';
import { isBotHook } from '@/hooks/is-bot.hook';
import type { FastifyPluginCallback } from 'fastify';
const eventRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preHandler', clientHook);

View File

@@ -1,5 +1,5 @@
import * as controller from '@/controllers/webhook.controller';
import type { FastifyPluginCallback } from 'fastify';
import * as controller from '@/controllers/webhook.controller';
const webhookRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({

View File

@@ -1,23 +1,18 @@
import { chartTypes } from '@openpanel/constants';
import type { IClickhouseSession } from '@openpanel/db';
import {
ch,
clix,
type IClickhouseEvent,
type IClickhouseProfile,
TABLE_NAMES,
ch,
clix,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { zReportInput } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
export function getReport({
projectId,
}: {
projectId: string;
}) {
export function getReport({ projectId }: { projectId: string }) {
return tool({
description: `Generate a report (a chart) for
- ${chartTypes.area}
@@ -67,11 +62,7 @@ export function getReport({
},
});
}
export function getConversionReport({
projectId,
}: {
projectId: string;
}) {
export function getConversionReport({ projectId }: { projectId: string }) {
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
@@ -92,11 +83,7 @@ export function getConversionReport({
},
});
}
export function getFunnelReport({
projectId,
}: {
projectId: string;
}) {
export function getFunnelReport({ projectId }: { projectId: string }) {
return tool({
description:
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
@@ -118,11 +105,7 @@ export function getFunnelReport({
});
}
export function getProfiles({
projectId,
}: {
projectId: string;
}) {
export function getProfiles({ projectId }: { projectId: string }) {
return tool({
description: 'Get profiles',
parameters: z.object({
@@ -188,11 +171,7 @@ export function getProfiles({
});
}
export function getProfile({
projectId,
}: {
projectId: string;
}) {
export function getProfile({ projectId }: { projectId: string }) {
return tool({
description: 'Get a specific profile',
parameters: z.object({
@@ -276,11 +255,7 @@ export function getProfile({
});
}
export function getEvents({
projectId,
}: {
projectId: string;
}) {
export function getEvents({ projectId }: { projectId: string }) {
return tool({
description: 'Get events for a project or specific profile',
parameters: z.object({
@@ -369,11 +344,7 @@ export function getEvents({
});
}
export function getSessions({
projectId,
}: {
projectId: string;
}) {
export function getSessions({ projectId }: { projectId: string }) {
return tool({
description: 'Get sessions for a project or specific profile',
parameters: z.object({
@@ -458,11 +429,7 @@ export function getSessions({
});
}
export function getAllEventNames({
projectId,
}: {
projectId: string;
}) {
export function getAllEventNames({ projectId }: { projectId: string }) {
return tool({
description: 'Get the top 50 event names in a comma separated list',
parameters: z.object({}),

View File

@@ -14,11 +14,7 @@ export const getChatModel = () => {
}
};
export const getChatSystemPrompt = ({
projectId,
}: {
projectId: string;
}) => {
export const getChatSystemPrompt = ({ projectId }: { projectId: string }) => {
return `You're an product and web analytics expert. Don't generate more than the user asks for. Follow all rules listed below!
## General:
- projectId: \`${projectId}\`

View File

@@ -1,5 +1,3 @@
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db';
@@ -10,6 +8,7 @@ import type {
IProjectFilterProfileId,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import { path } from 'ramda';
const cleanDomain = (domain: string) =>
@@ -31,7 +30,7 @@ export class SdkAuthError extends Error {
clientId?: string;
clientSecret?: string;
origin?: string;
},
}
) {
super(message);
this.name = 'SdkAuthError';
@@ -43,7 +42,7 @@ export class SdkAuthError extends Error {
export async function validateSdkRequest(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
}>
): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req;
const clientIdNew = headers['openpanel-client-id'] as string;
@@ -70,7 +69,7 @@ export async function validateSdkRequest(
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
clientId
)
) {
throw createError('Ingestion: Client ID must be a valid UUIDv4');
@@ -88,7 +87,7 @@ export async function validateSdkRequest(
// Filter out blocked IPs
const ipFilter = client.project.filters.filter(
(filter): filter is IProjectFilterIp => filter.type === 'ip',
(filter): filter is IProjectFilterIp => filter.type === 'ip'
);
if (ipFilter.some((filter) => filter.ip === clientIp)) {
throw createError('Ingestion: IP address is blocked by project filter');
@@ -96,7 +95,7 @@ export async function validateSdkRequest(
// Filter out blocked profile ids
const profileFilter = client.project.filters.filter(
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id',
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id'
);
const profileId =
path<string | undefined>(['payload', 'profileId'], req.body) || // Track handler
@@ -113,12 +112,11 @@ export async function validateSdkRequest(
// Only allow revenue tracking if it was sent with a client secret
// or if the project has allowUnsafeRevenueTracking enabled
if (
!client.project.allowUnsafeRevenueTracking &&
!clientSecret &&
!(client.project.allowUnsafeRevenueTracking || clientSecret) &&
typeof revenue !== 'undefined'
) {
throw createError(
'Ingestion: Revenue tracking is not allowed without a client secret',
'Ingestion: Revenue tracking is not allowed without a client secret'
);
}
@@ -132,7 +130,7 @@ export async function validateSdkRequest(
// support wildcard domains `*.foo.com`
if (cleanedDomain.includes('*')) {
const regex = new RegExp(
`${cleanedDomain.replaceAll('.', '\\.').replaceAll('*', '.+?')}`,
`${cleanedDomain.replaceAll('.', '\\.').replaceAll('*', '.+?')}`
);
return regex.test(origin || '');
@@ -157,7 +155,7 @@ export async function validateSdkRequest(
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
true
);
if (isVerified) {
return client;
@@ -168,14 +166,14 @@ export async function validateSdkRequest(
}
export async function validateExportRequest(
headers: RawRequestDefaultExpression['headers'],
headers: RawRequestDefaultExpression['headers']
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
clientId
)
) {
throw new Error('Export: Client ID must be a valid UUIDv4');
@@ -203,14 +201,14 @@ export async function validateExportRequest(
}
export async function validateImportRequest(
headers: RawRequestDefaultExpression['headers'],
headers: RawRequestDefaultExpression['headers']
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
clientId
)
) {
throw new Error('Import: Client ID must be a valid UUIDv4');
@@ -238,14 +236,14 @@ export async function validateImportRequest(
}
export async function validateManageRequest(
headers: RawRequestDefaultExpression['headers'],
headers: RawRequestDefaultExpression['headers']
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
clientId
)
) {
throw new Error('Manage: Client ID must be a valid UUIDv4');
@@ -263,7 +261,7 @@ export async function validateManageRequest(
if (client.type !== ClientType.root) {
throw new Error(
'Manage: Only root clients are allowed to manage resources',
'Manage: Only root clients are allowed to manage resources'
);
}

View File

@@ -20,10 +20,10 @@ export async function isDuplicatedEvent({
origin,
projectId,
},
'md5',
'md5'
)}`,
'1',
100,
100
);
if (locked) {

View File

@@ -4,7 +4,7 @@ export class LogError extends Error {
constructor(
message: string,
payload?: Record<string, unknown>,
options?: ErrorOptions,
options?: ErrorOptions
) {
super(message, options);
this.name = 'LogError';
@@ -26,7 +26,7 @@ export class HttpError extends Error {
fingerprint?: string;
extra?: Record<string, unknown>;
error?: Error | unknown;
},
}
) {
super(message);
this.name = 'HttpError';

View File

@@ -29,7 +29,7 @@ export function isShuttingDown() {
export async function shutdown(
fastify: FastifyInstance,
signal: string,
exitCode = 0,
exitCode = 0
) {
if (isShuttingDown()) {
logger.warn('Shutdown already in progress, ignoring signal', { signal });
@@ -96,7 +96,7 @@ export async function shutdown(
if (redis.status === 'ready') {
await redis.quit();
}
}),
})
);
logger.info('Redis connections closed');
} catch (error) {

View File

@@ -3,14 +3,21 @@ import { getSafeJson } from '@openpanel/json';
export const parseQueryString = (obj: Record<string, any>): any => {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => {
if (typeof v === 'object') return [k, parseQueryString(v)];
if (typeof v === 'object') {
return [k, parseQueryString(v)];
}
if (
/^-?[0-9]+(\.[0-9]+)?$/i.test(v) &&
!Number.isNaN(Number.parseFloat(v))
)
) {
return [k, Number.parseFloat(v)];
if (v === 'true') return [k, true];
if (v === 'false') return [k, false];
}
if (v === 'true') {
return [k, true];
}
if (v === 'false') {
return [k, false];
}
if (typeof v === 'string') {
if (getSafeJson(v) !== null) {
return [k, getSafeJson(v)];
@@ -18,6 +25,6 @@ export const parseQueryString = (obj: Record<string, any>): any => {
return [k, v];
}
return [k, null];
}),
})
);
};

View File

@@ -19,7 +19,7 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
favicon.rel === 'apple-touch-icon'
);
if (match) {

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'tsdown';
import type { Options } from 'tsdown';
import { defineConfig } from 'tsdown';
const options: Options = {
clean: true,

View File

@@ -1,40 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
<meta name="author" content="OpenPanel">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta property="og:image" content="/ogimage.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="twitter:image" content="/ogimage.png">
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark">
<style>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta
name="title"
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
>
<meta
name="description"
content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted."
>
<meta
name="keywords"
content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics"
>
<meta name="author" content="OpenPanel">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta
property="og:title"
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
>
<meta
property="og:description"
content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted."
>
<meta property="og:image" content="/ogimage.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta
name="twitter:title"
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
>
<meta
name="twitter:description"
content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted."
>
<meta name="twitter:image" content="/ogimage.png">
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark">
<style>
* {
margin: 0;
padding: 0;
@@ -44,7 +65,9 @@
body {
background: #0a0a0a;
color: #e5e5e5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
font-size: 18px;
line-height: 1.75;
padding: 2rem 1.5rem;
@@ -100,7 +123,8 @@
color: #60a5fa;
}
ul, ol {
ul,
ol {
margin-left: 1.5rem;
margin-bottom: 1.25em;
}
@@ -123,7 +147,8 @@
margin: 2rem 0;
}
th, td {
th,
td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #374151;
@@ -264,242 +289,479 @@
color: #9ca3af;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>Just Fucking Use OpenPanel</h1>
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>Just Fucking Use OpenPanel</h1>
<p>
Stop settling for basic metrics. Get real insights that actually help
you build a better product.
</p>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img
src="screenshots/realtime-dark.webp"
alt="OpenPanel Real-time Analytics"
width="1400"
height="800"
>
</div>
</div>
<figcaption>
Real-time analytics - see events as they happen. No waiting, no
delays.
</figcaption>
</figure>
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
<p>
Let's talk about what happens when you have a
<strong>real product</strong> with <strong>real users</strong>.
</p>
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
<ul>
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
</ul>
<p>
"1 million free events!" they scream. Cute. Until you have an actual
product with actual users doing actual things. Then suddenly you need to
"talk to sales" and your wallet starts bleeding.
</p>
<p>
Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X.
HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're
hemorrhaging money just to understand what your users are doing, you
magnificent fool.
</p>
<h2>The Web-Only Analytics Trap</h2>
<p>
You built a great fucking product. You have real traffic. Thousands,
tens of thousands of visitors. But you're flying blind.
</p>
<blockquote>
"Congrats, 50,000 visitors from France this month. Why didn't a single
one buy your baguette?"
</blockquote>
<p>
You see the traffic. You see the bounce rate. You see the referrers. You
see where they're from. You have <strong>NO FUCKING IDEA</strong> what
users actually do.
</p>
<p>
Where do they drop off? Do they come back? What features do they use?
Why didn't they convert? Who the fuck knows! You're using a glorified
hit counter with a pretty dashboard that tells you everything about
geography and nothing about behavior.
</p>
<p>
Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch.
They're all the same story: simple analytics with some goals you can
define. Page views, visitors, countries, basic funnels. That's it. No
retention analysis. No user profiles. No event tracking. No cohorts. No
revenue tracking. Just... basic web analytics.
</p>
<p>
And when you finally need to understand your users—when you need to see
where they drop off in your signup flow, or which features drive
retention, or why your conversion rate is shit—you end up paying for a
<strong>SECOND tool</strong> on top. Now you're paying for two
subscriptions, managing two dashboards, and your users' data is split
across two platforms like a bad divorce.
</p>
<h2>Counter One Dollar Stats</h2>
<p>"$1/month for page views. Adorable."</p>
<p>
Look, I get it. A dollar is cheap. But you're getting exactly what you
pay for: page views. That's it. No funnels. No retention. No user
profiles. No event tracking. Just... page views.
</p>
<p>
Here's the thing: if you want to make <strong>good decisions</strong>
about your product, you need to understand
<strong>what your users are actually doing</strong>, not just where the
fuck they're from.
</p>
<p>
OpenPanel gives you the full product analytics suite. Or self-host for
<strong>FREE</strong> with <strong>UNLIMITED events</strong>.
</p>
<p>You get:</p>
<ul>
<li>Funnels to see where users drop off</li>
<li>Retention analysis to see who comes back</li>
<li>Cohorts to segment your users</li>
<li>User profiles to understand individual behavior</li>
<li>Custom dashboards to see what matters to YOU</li>
<li>Revenue tracking to see what actually makes money</li>
<li>
All the web analytics (page views, visitors, referrers) that the other
tools give you
</li>
</ul>
<p>
One Dollar Stats tells you 50,000 people visited from France. OpenPanel
tells you why they didn't buy your baguette. That's the difference
between vanity metrics and actual insights.
</p>
<h2>Why OpenPanel is the Answer</h2>
<p>
You want analytics that actually help you build a better product. Not
vanity metrics. Not enterprise pricing. Not two separate tools.
</p>
<p>
To make good decisions, you need to understand
<strong>what your users are doing</strong>, not just where they're from.
You need to see where they drop off. You need to know which features
they use. You need to understand why they convert or why they don't.
</p>
<ul>
<li>
<strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it,
audit it, own it. Self-host for FREE with unlimited events, or use our
cloud
</li>
<li>
<strong>Price</strong>: Affordable pricing that scales, or FREE
self-hosted (unlimited events, forever)
</li>
<li>
<strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x
bigger, you performance-obsessed maniac)
</li>
<li>
<strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or
your own servers if you self-host)
</li>
<li>
<strong>Full Suite</strong>: Web analytics + product analytics in one
tool. No need for two subscriptions.
</li>
</ul>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img
src="screenshots/overview-dark.webp"
alt="OpenPanel Overview Dashboard"
width="1400"
height="800"
>
</div>
</div>
<figcaption>
OpenPanel overview showing web analytics and product analytics in one
clean interface
</figcaption>
</figure>
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
<p>
Tired of watching your analytics bill grow every month? Tired of "talk
to sales" when you hit their arbitrary limits? Tired of paying
$2,000+/month just to understand your users?
</p>
<p>
<strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can
fork it. You can audit it. You can own it. And you can
<strong>self-host it for FREE with UNLIMITED events</strong>.
</p>
<p>
That's right. Zero dollars. Unlimited events. All the features. Your
data on your servers. No vendor lock-in. No surprise bills. No
"enterprise sales" calls.
</p>
<p>
Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel
self-hosted? <strong>$0/month</strong>. Forever.
</p>
<p>
Don't want to manage infrastructure? That's fine. Use our cloud. But if
you want to escape the pricing hell entirely, self-hosting is a Docker
command away. Your data, your rules, your wallet.
</p>
<h2>The Comparison Table (The Brutal Truth)</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Price at 20M events</th>
<th>What You Get</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mixpanel</strong></td>
<td>$2,300+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>PostHog</strong></td>
<td>$1,982+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>Plausible</strong></td>
<td>Various pricing</td>
<td>
Simple analytics with basic goals. Page views and visitors. That's
it.
</td>
</tr>
<tr>
<td><strong>One Dollar Stats</strong></td>
<td>$1/month</td>
<td>Page views (but cheaper!)</td>
</tr>
<tr style="background: #131313; border: 2px solid #3b82f6;">
<td><strong>OpenPanel</strong></td>
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
<td>
<strong
>Web + Product analytics. The full package. Open source. Your
data.</strong
>
</td>
</tr>
</tbody>
</table>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img
src="screenshots/profile-dark.webp"
alt="OpenPanel User Profiles"
width="1400"
height="800"
>
</div>
</div>
<figcaption>
User profiles - see individual user journeys and behavior. Something
web-only tools can't give you.
</figcaption>
</figure>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img
src="screenshots/report-dark.webp"
alt="OpenPanel Reports and Funnels"
width="1400"
height="800"
>
</div>
</div>
<figcaption>
Funnels, retention, and custom reports - the features you CAN'T get
with web-only tools
</figcaption>
</figure>
<h2>The Bottom Fucking Line</h2>
<p>
If you want to make good decisions about your product, you need to
understand what your users are actually doing. Not just where they're
from. Not just how many page views you got. You need to see the full
picture: funnels, retention, user behavior, conversion paths.
</p>
<p>You have three choices:</p>
<ol>
<li>
Keep using Google Analytics like a data-harvesting accomplice, adding
cookie banners, annoying your users, and contributing to the dystopian
surveillance economy
</li>
<li>
Pay $2,000+/month for Mixpanel or PostHog when you scale, or use
simple web-only analytics that tell you nothing about user
behavior—just where they're from
</li>
<li>
Use OpenPanel (affordable pricing or FREE self-hosted) and get the
full analytics suite: web analytics AND product analytics in one tool,
so you can actually understand what your users do
</li>
</ol>
<p>
If you picked option 1 or 2, I can't help you. You're beyond saving. Go
enjoy your complicated, privacy-violating, overpriced analytics life
where you know everything about where your users are from but nothing
about what they actually do.
</p>
<p>
But if you have even one functioning brain cell, you'll realize that
OpenPanel gives you everything you need—web analytics AND product
analytics—for a fraction of what the enterprise tools cost. You'll
finally understand what your users are doing, not just where the fuck
they're from.
</p>
<div class="cta">
<h2>Ready to understand what your users actually do?</h2>
<p>
Stop settling for vanity metrics. Get the full analytics suite—web
analytics AND product analytics—so you can make better decisions. Or
self-host for free.
</p>
<a href="https://openpanel.dev" target="_blank"
>Get Started with OpenPanel</a
>
<a
href="https://openpanel.dev/docs/self-hosting/self-hosting"
target="_blank"
>Self-Host Guide</a
>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img
src="screenshots/dashboard-dark.webp"
alt="OpenPanel Custom Dashboards"
width="1400"
height="800"
>
</div>
</div>
<figcaption>
Custom dashboards - build exactly what you need to understand your
product
</figcaption>
</figure>
<footer>
<p><strong>Just Fucking Use OpenPanel</strong></p>
<p>
Inspired by
<a
href="https://justfuckingusereact.com"
target="_blank"
rel="nofollow"
>justfuckingusereact.com</a
>, <a
href="https://justfuckingusehtml.com"
target="_blank"
rel="nofollow"
>justfuckingusehtml.com</a
>, and
<a
href="https://justfuckinguseonedollarstats.com"
target="_blank"
rel="nofollow"
>justfuckinguseonedollarstats.com</a
>
and all other just fucking use sites.
</p>
<p style="margin-top: 1rem;">
This is affiliated with
<a href="https://openpanel.dev" target="_blank" rel="nofollow"
>OpenPanel</a
>. We still love all products mentioned in this website, and we're
grateful for them and what they do 🫶
</p>
</footer>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
</div>
</div>
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
</figure>
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
<ul>
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
</ul>
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
<h2>The Web-Only Analytics Trap</h2>
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
<blockquote>
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
</blockquote>
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
<h2>Counter One Dollar Stats</h2>
<p>"$1/month for page views. Adorable."</p>
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
<p>You get:</p>
<ul>
<li>Funnels to see where users drop off</li>
<li>Retention analysis to see who comes back</li>
<li>Cohorts to segment your users</li>
<li>User profiles to understand individual behavior</li>
<li>Custom dashboards to see what matters to YOU</li>
<li>Revenue tracking to see what actually makes money</li>
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
</ul>
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
<h2>Why OpenPanel is the Answer</h2>
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
<ul>
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
</ul>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
</div>
</div>
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
</figure>
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
<h2>The Comparison Table (The Brutal Truth)</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Price at 20M events</th>
<th>What You Get</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mixpanel</strong></td>
<td>$2,300+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>PostHog</strong></td>
<td>$1,982+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>Plausible</strong></td>
<td>Various pricing</td>
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
</tr>
<tr>
<td><strong>One Dollar Stats</strong></td>
<td>$1/month</td>
<td>Page views (but cheaper!)</td>
</tr>
<tr style="background: #131313; border: 2px solid #3b82f6;">
<td><strong>OpenPanel</strong></td>
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
</tr>
</tbody>
</table>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
</div>
</div>
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
</figure>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
</div>
</div>
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
</figure>
<h2>The Bottom Fucking Line</h2>
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
<p>You have three choices:</p>
<ol>
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
</ol>
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
<div class="cta">
<h2>Ready to understand what your users actually do?</h2>
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
</div>
</div>
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
</figure>
<footer>
<p><strong>Just Fucking Use OpenPanel</strong></p>
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
</footer>
</div>
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
<script>
'use strict';
window.op =
window.op ||
(() => {
var n = [];
return new Proxy(
function () {
arguments.length && n.push([].slice.call(arguments));
},
{
get(t, r) {
returnr === 'q'
? n
: function () {
n.push([r].concat([].slice.call(arguments)));
};
},
has(t, r) {
returnr === 'q';
},
}
);
})();
window.op('init', {
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
</body>
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
</body>
</html>

View File

@@ -4,4 +4,4 @@
"assets": {
"directory": "."
}
}
}

View File

@@ -1,15 +1,15 @@
import { cn } from '@/lib/utils';
import {
CheckIcon,
HeartHandshakeIcon,
MessageSquareIcon,
PackageIcon,
RocketIcon,
SparklesIcon,
StarIcon,
ZapIcon,
PackageIcon,
} from 'lucide-react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
const perks = [
{
@@ -52,17 +52,17 @@ export function SupporterPerks({ className }: { className?: string }) {
return (
<div
className={cn(
'col gap-4 p-6 rounded-xl border bg-card',
'col gap-4 rounded-xl border bg-card p-6',
'sticky top-24',
className,
className
)}
>
<div className="col gap-2 mb-2">
<div className="row gap-2 items-center">
<div className="col mb-2 gap-2">
<div className="row items-center gap-2">
<HeartHandshakeIcon className="size-5 text-primary" />
<h3 className="font-semibold text-lg">Supporter Perks</h3>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Everything you get when you support OpenPanel
</p>
</div>
@@ -72,42 +72,42 @@ export function SupporterPerks({ className }: { className?: string }) {
const Icon = perk.icon;
return (
<div
key={index}
className={cn(
'col gap-1.5 p-3 rounded-lg border transition-colors',
'col gap-1.5 rounded-lg border p-3 transition-colors',
perk.highlight
? 'bg-primary/5 border-primary/20'
: 'bg-background border-border',
? 'border-primary/20 bg-primary/5'
: 'border-border bg-background'
)}
key={index}
>
<div className="row gap-2 items-start">
<div className="row items-start gap-2">
<Icon
className={cn(
'size-4 mt-0.5 shrink-0',
perk.highlight ? 'text-primary' : 'text-muted-foreground',
'mt-0.5 size-4 shrink-0',
perk.highlight ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="col gap-0.5 flex-1 min-w-0">
<div className="row gap-2 items-center">
<div className="col min-w-0 flex-1 gap-0.5">
<div className="row items-center gap-2">
<h4
className={cn(
'font-medium text-sm',
perk.highlight && 'text-primary',
perk.highlight && 'text-primary'
)}
>
{perk.title}
</h4>
{perk.highlight && (
<CheckIcon className="size-3.5 text-primary shrink-0" />
<CheckIcon className="size-3.5 shrink-0 text-primary" />
)}
</div>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{perk.description}
</p>
{perk.href && (
<Link
className="mt-1 text-primary text-xs hover:underline"
href={perk.href}
className="text-xs text-primary hover:underline mt-1"
>
Learn more
</Link>
@@ -119,12 +119,11 @@ export function SupporterPerks({ className }: { className?: string }) {
})}
</div>
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground text-center">
<div className="mt-4 border-t pt-4">
<p className="text-center text-muted-foreground text-xs">
Starting at <strong className="text-foreground">$20/month</strong>
</p>
</div>
</div>
);
}

View File

@@ -549,4 +549,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -9,12 +9,7 @@
"hero": {
"heading": "Best Amplitude Alternatives",
"subheading": "OpenPanel is an open-source, privacy-first alternative to Amplitude. Get powerful product analytics with web analytics built in, cookie-free tracking, and the freedom to self-host or use our cloud.",
"badges": [
"Open-source",
"Cookie-free",
"EU-only hosting",
"Self-hostable"
]
"badges": ["Open-source", "Cookie-free", "EU-only hosting", "Self-hostable"]
},
"competitor": {
"name": "Amplitude",
@@ -562,4 +557,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -468,4 +468,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -520,4 +520,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -9,12 +9,7 @@
"hero": {
"heading": "Best Countly Alternative",
"subheading": "Want Countly's product analytics without the complexity? OpenPanel offers a simpler, more affordable approach to user analytics with self-hosting, mobile SDKs, and modern product analytics - all with transparent pricing.",
"badges": [
"Open-source",
"Simple Pricing",
"Lightweight",
"MIT License"
]
"badges": ["Open-source", "Simple Pricing", "Lightweight", "MIT License"]
},
"competitor": {
"name": "Countly",
@@ -560,4 +555,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -274,9 +274,7 @@
"Android",
"Flutter"
],
"competitor": [
"JavaScript (web only)"
],
"competitor": ["JavaScript (web only)"],
"notes": null
},
{
@@ -458,4 +456,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { readFile, readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
interface FileStructure {
@@ -10,7 +10,7 @@ interface FileStructure {
}
async function analyzeJsonFiles(): Promise<void> {
const dirPath = join(import.meta.dirname || __dirname);
const dirPath = join(import.meta.dirname || import.meta.dirname);
const files = await readdir(dirPath);
const jsonFiles = files.filter((f) => f.endsWith('.json'));
@@ -88,7 +88,7 @@ async function analyzeJsonFiles(): Promise<void> {
console.log(separator);
const sortedGroups = Array.from(groups.entries()).sort(
(a, b) => b[1].length - a[1].length,
(a, b) => b[1].length - a[1].length
);
sortedGroups.forEach(([structureKey, files], index) => {
@@ -117,7 +117,7 @@ async function analyzeJsonFiles(): Promise<void> {
console.log(separator);
const validFiles = structures.filter((s) => s.hasContent && !s.error);
const emptyFiles = structures.filter((s) => !s.hasContent && !s.error);
const emptyFiles = structures.filter((s) => !(s.hasContent || s.error));
const errorFiles = structures.filter((s) => s.error);
console.log(` Total files: ${structures.length}`);
@@ -148,7 +148,9 @@ async function analyzeJsonFiles(): Promise<void> {
console.log(separator);
sortedGroups.forEach(([structureKey, files], index) => {
if (structureKey === 'empty' || structureKey === 'error') return;
if (structureKey === 'empty' || structureKey === 'error') {
return;
}
const groupNum = index + 1;
console.log(`\nGroup ${groupNum} structure:`);

View File

@@ -9,12 +9,7 @@
"hero": {
"heading": "Best Fathom Alternative",
"subheading": "Love Fathom's simplicity and privacy focus? OpenPanel adds product analytics capabilities - funnels, cohorts, retention, and user identification - plus self-hosting options and a free tier.",
"badges": [
"Open-source",
"Privacy-first",
"Self-hostable",
"Free Tier"
]
"badges": ["Open-source", "Privacy-first", "Self-hostable", "Free Tier"]
},
"competitor": {
"name": "Fathom Analytics",
@@ -513,4 +508,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -468,4 +468,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -514,4 +514,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -588,4 +588,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -587,4 +587,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -472,4 +472,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -9,12 +9,7 @@
"hero": {
"heading": "Best Matomo Alternative",
"subheading": "OpenPanel is a modern, open-source alternative to Matomo. Get powerful web and product analytics with a cleaner interface, truly cookie-free tracking by default, and no premium plugins required for essential features.",
"badges": [
"Open-source",
"Cookie-free",
"EU-only hosting",
"Self-hostable"
]
"badges": ["Open-source", "Cookie-free", "EU-only hosting", "Self-hostable"]
},
"competitor": {
"name": "Matomo",
@@ -511,4 +506,4 @@
}
]
}
}
}

View File

@@ -533,4 +533,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -525,4 +525,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -280,9 +280,7 @@
"Android",
"Flutter"
],
"competitor": [
"JavaScript (web only)"
],
"competitor": ["JavaScript (web only)"],
"notes": null
},
{
@@ -464,4 +462,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -514,4 +514,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -519,4 +519,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -518,4 +518,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -447,4 +447,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -548,4 +548,4 @@
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}
}

View File

@@ -1,3 +1,9 @@
{
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
"pages": [
"sdks",
"how-it-works",
"session-replay",
"consent-management",
"..."
]
}

View File

@@ -1,7 +1,4 @@
{
"title": "Dashboard",
"pages": [
"understand-the-overview",
"..."
]
}
"pages": ["understand-the-overview", "..."]
}

View File

@@ -15,4 +15,4 @@
"---Migration---",
"...migration"
]
}
}

View File

@@ -12,4 +12,4 @@
"supporter-access-latest-docker-images",
"changelog"
]
}
}

View File

@@ -164,4 +164,4 @@
"label": "Track your first conversion",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -172,4 +172,4 @@
"label": "Start visualizing your data",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -136,4 +136,4 @@
"label": "Track events in minutes",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -136,4 +136,4 @@
"label": "Build your first funnel",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -155,4 +155,4 @@
"label": "Start identifying users today",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -165,4 +165,4 @@
"label": "Set up your first integration",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -164,4 +164,4 @@
"label": "Set up your first notification rule",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -4,10 +4,7 @@
"seo": {
"title": "Retention & Cohort Analysis",
"description": "User retention analytics and cohort analysis that show who comes back. See product stickiness at a glance-no sampling, no guesswork. Built on your events.",
"keywords": [
"user retention analytics",
"cohort analysis"
]
"keywords": ["user retention analytics", "cohort analysis"]
},
"hero": {
"heading": "Retention: Who comes back?",
@@ -135,4 +132,4 @@
"label": "See your retention in minutes",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -154,4 +154,4 @@
"label": "Start tracking revenue",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -163,4 +163,4 @@
"label": "Start recording sessions",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -165,4 +165,4 @@
"label": "Invite your team and share your first dashboard",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -178,4 +178,4 @@
"label": "Add analytics in 2 minutes",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -151,16 +151,34 @@
"related_links": {
"guides": [
{ "title": "Script tag SDK setup", "url": "/docs/sdks/script" },
{ "title": "Migrate from Google Analytics", "url": "/guides/migrate-from-google-analytics" }
{
"title": "Migrate from Google Analytics",
"url": "/guides/migrate-from-google-analytics"
}
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "How to self-host OpenPanel", "url": "/articles/how-to-self-host-openpanel" },
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
{
"title": "Cookieless analytics explained",
"url": "/articles/cookieless-analytics"
},
{
"title": "How to self-host OpenPanel",
"url": "/articles/how-to-self-host-openpanel"
},
{
"title": "Best open source analytics tools",
"url": "/articles/open-source-web-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
{
"title": "OpenPanel vs Google Analytics",
"url": "/compare/google-analytics-alternative"
},
{
"title": "OpenPanel vs Plausible",
"url": "/compare/plausible-alternative"
},
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
]
},

View File

@@ -156,13 +156,28 @@
{ "title": "Python analytics setup", "url": "/guides/python-analytics" }
],
"articles": [
{ "title": "How to self-host OpenPanel", "url": "/articles/how-to-self-host-openpanel" },
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" },
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" }
{
"title": "How to self-host OpenPanel",
"url": "/articles/how-to-self-host-openpanel"
},
{
"title": "Best open source analytics tools",
"url": "/articles/open-source-web-analytics"
},
{
"title": "Cookieless analytics explained",
"url": "/articles/cookieless-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
{
"title": "OpenPanel vs PostHog",
"url": "/compare/posthog-alternative"
},
{
"title": "OpenPanel vs Plausible",
"url": "/compare/plausible-alternative"
},
{ "title": "OpenPanel vs Umami", "url": "/compare/umami-alternative" }
]
},

View File

@@ -135,10 +135,16 @@
{ "title": "Track custom events", "url": "/guides/track-custom-events" }
],
"articles": [
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
{
"title": "Self-hosted web analytics",
"url": "/articles/self-hosted-web-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{
"title": "OpenPanel vs Google Analytics",
"url": "/compare/google-analytics-alternative"
},
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" }
]
},

View File

@@ -143,13 +143,28 @@
{ "title": "React analytics setup", "url": "/guides/react-analytics" }
],
"articles": [
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
{
"title": "How to create a funnel",
"url": "/articles/how-to-create-a-funnel"
},
{
"title": "Self-hosted web analytics",
"url": "/articles/self-hosted-web-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
{
"title": "OpenPanel vs Mixpanel",
"url": "/compare/mixpanel-alternative"
},
{
"title": "OpenPanel vs PostHog",
"url": "/compare/posthog-alternative"
},
{
"title": "OpenPanel vs Amplitude",
"url": "/compare/amplitude-alternative"
}
]
},
"ctas": {

View File

@@ -134,15 +134,30 @@
},
"related_links": {
"guides": [
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" }
{
"title": "Ecommerce tracking setup",
"url": "/guides/ecommerce-tracking"
}
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
{
"title": "Cookieless analytics explained",
"url": "/articles/cookieless-analytics"
},
{
"title": "Best open source analytics tools",
"url": "/articles/open-source-web-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" }
{
"title": "OpenPanel vs Google Analytics",
"url": "/compare/google-analytics-alternative"
},
{
"title": "OpenPanel vs Plausible",
"url": "/compare/plausible-alternative"
}
]
},
"ctas": {

View File

@@ -152,17 +152,38 @@
"guides": [
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
{ "title": "React analytics setup", "url": "/guides/react-analytics" },
{ "title": "Migrate from Google Analytics", "url": "/guides/migrate-from-google-analytics" }
{
"title": "Migrate from Google Analytics",
"url": "/guides/migrate-from-google-analytics"
}
],
"articles": [
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" },
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
{ "title": "Cookieless analytics guide", "url": "/articles/cookieless-analytics" }
{
"title": "Best open source analytics tools",
"url": "/articles/open-source-web-analytics"
},
{
"title": "How to create a funnel",
"url": "/articles/how-to-create-a-funnel"
},
{
"title": "Cookieless analytics guide",
"url": "/articles/cookieless-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
{
"title": "OpenPanel vs Mixpanel",
"url": "/compare/mixpanel-alternative"
},
{
"title": "OpenPanel vs PostHog",
"url": "/compare/posthog-alternative"
},
{
"title": "OpenPanel vs Amplitude",
"url": "/compare/amplitude-alternative"
}
]
},
"ctas": {

View File

@@ -130,17 +130,38 @@
},
"related_links": {
"guides": [
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" },
{ "title": "Website analytics setup", "url": "/guides/website-analytics-setup" },
{ "title": "OpenPanel WordPress plugin", "url": "https://sv.wordpress.org/plugins/openpanel/" }
{
"title": "Ecommerce tracking setup",
"url": "/guides/ecommerce-tracking"
},
{
"title": "Website analytics setup",
"url": "/guides/website-analytics-setup"
},
{
"title": "OpenPanel WordPress plugin",
"url": "https://sv.wordpress.org/plugins/openpanel/"
}
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "How to self-host OpenPanel", "url": "/articles/self-hosted-web-analytics" }
{
"title": "Cookieless analytics explained",
"url": "/articles/cookieless-analytics"
},
{
"title": "How to self-host OpenPanel",
"url": "/articles/self-hosted-web-analytics"
}
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
{
"title": "OpenPanel vs Google Analytics",
"url": "/compare/google-analytics-alternative"
},
{
"title": "OpenPanel vs Plausible",
"url": "/compare/plausible-alternative"
},
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
]
},

View File

@@ -61,4 +61,4 @@
"typescript": "catalog:",
"wrangler": "^4.65.0"
}
}
}

View File

@@ -1 +1 @@
google-site-verification: google30d28bbdbd56aa6e.html
google-site-verification: google30d28bbdbd56aa6e.html

View File

@@ -1,2 +1,254 @@
"use strict";(()=>{function v(r){return Promise.all(Object.entries(r).map(async([e,i])=>[e,await i??""])).then(e=>Object.fromEntries(e))}function m(r){let e={"Content-Type":"application/json"};return{headers:e,async fetch(i,t,n){let s=`${r}${i}`,o,a=await v(e);return new Promise(p=>{let c=l=>{clearTimeout(o),fetch(s,{headers:a,method:"POST",body:JSON.stringify(t??{}),keepalive:!0,...n??{}}).then(async d=>{if(d.status===401)return null;if(d.status!==200&&d.status!==202)return h(l,p);let g=await d.text();if(!g)return p(null);p(g)}).catch(()=>h(l,p))};function h(l,d){if(l>1)return d(null);o=setTimeout(()=>{c(l+1)},Math.pow(2,l)*500)}c(0)})}}}var u=class{constructor(e){this.state={properties:{}};this.options=e,this.api=m(e.url??"https://api.openpanel.dev"),this.api.headers["openpanel-client-id"]=e.clientId,this.options.clientSecret&&(this.api.headers["openpanel-client-secret"]=this.options.clientSecret)}setProfileId(e){this.state.profileId=e}setProfile(e){this.setProfileId(e.profileId),this.api.fetch("/profile",{...e,properties:{...this.state.properties,...e.properties}})}increment(e,i,t){let n=t?.profileId??this.state.profileId;if(!n)return console.log("No profile id");this.api.fetch("/profile/increment",{profileId:n,property:e,value:i})}decrement(e,i,t){let n=t?.profileId??this.state.profileId;if(!n)return console.log("No profile id");this.api.fetch("/profile/decrement",{profileId:n,property:e,value:i})}event(e,i){let t=i?.profileId??this.state.profileId;delete i?.profileId,this.api.fetch("/event",{name:e,properties:{...this.state.properties,...i??{}},timestamp:this.timestamp(),deviceId:this.getDeviceId(),profileId:t}).then(n=>{this.options.setDeviceId&&n&&this.options.setDeviceId(n)})}setGlobalProperties(e){this.state.properties={...this.state.properties,...e}}clear(){this.state.deviceId=void 0,this.state.profileId=void 0,this.options.removeDeviceId&&this.options.removeDeviceId()}timestamp(){return new Date().toISOString()}getDeviceId(){if(this.state.deviceId)return this.state.deviceId;this.options.getDeviceId&&(this.state.deviceId=this.options.getDeviceId()||void 0)}};function b(r){return r.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}var f=class extends u{constructor(i){super(i);this.lastPath="";this.isServer()||(this.setGlobalProperties({__referrer:document.referrer}),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews(),this.options.trackAttributes&&this.trackAttributes())}debounce(i,t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(i,t)}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",i=>{let t=i.target,n=t.closest("a");if(n&&t){let s=n.getAttribute("href");s?.startsWith("http")&&super.event("link_out",{href:s,text:n.innerText||n.getAttribute("title")||t.getAttribute("alt")||t.getAttribute("title")})}})}trackScreenViews(){if(this.isServer())return;let i=history.pushState;history.pushState=function(...o){let a=i.apply(this,o);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),a};let t=history.replaceState;history.replaceState=function(...o){let a=t.apply(this,o);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),a},window.addEventListener("popstate",function(){window.dispatchEvent(new Event("locationchange"))});let n=()=>this.debounce(()=>this.screenView(),50);this.options.hash?window.addEventListener("hashchange",n):window.addEventListener("locationchange",n),setTimeout(()=>n(),50)}trackAttributes(){this.isServer()||document.addEventListener("click",i=>{let t=i.target,n=t.closest("button"),s=t.closest("a"),o=n?.getAttribute("data-event")?n:s?.getAttribute("data-event")?s:null;if(o){let a={};for(let c of o.attributes)c.name.startsWith("data-")&&c.name!=="data-event"&&(a[b(c.name.replace(/^data-/,""))]=c.value);let p=o.getAttribute("data-event");p&&super.event(p,a)}})}screenView(i){if(this.isServer())return;let t=window.location.href;this.lastPath!==t&&(this.lastPath=t,super.event("screen_view",{...i??{},__path:t,__title:document.title}))}};(r=>{if(r.op&&"q"in r.op){let e=r.op.q||[],i=new f(e.shift()[1]);e.forEach(t=>{t[0]in i&&i[t[0]](...t.slice(1))}),r.op=(t,...n)=>{let s=i[t].bind(i);typeof s=="function"&&s(...n)}}})(window);})();
//# sourceMappingURL=cdn.global.js.map
(() => {
function v(r) {
return Promise.all(
Object.entries(r).map(async ([e, i]) => [e, (await i) ?? ''])
).then((e) => Object.fromEntries(e));
}
function m(r) {
const e = { 'Content-Type': 'application/json' };
return {
headers: e,
async fetch(i, t, n) {
let s = `${r}${i}`,
o,
a = await v(e);
return new Promise((p) => {
const c = (l) => {
clearTimeout(o),
fetch(s, {
headers: a,
method: 'POST',
body: JSON.stringify(t ?? {}),
keepalive: !0,
...(n ?? {}),
})
.then(async (d) => {
if (d.status === 401) {
return null;
}
if (d.status !== 200 && d.status !== 202) {
return h(l, p);
}
const g = await d.text();
if (!g) {
return p(null);
}
p(g);
})
.catch(() => h(l, p));
};
function h(l, d) {
if (l > 1) {
return d(null);
}
o = setTimeout(
() => {
c(l + 1);
},
2 ** l * 500
);
}
c(0);
});
},
};
}
var u = class {
constructor(e) {
this.state = { properties: {} };
(this.options = e),
(this.api = m(e.url ?? 'https://api.openpanel.dev')),
(this.api.headers['openpanel-client-id'] = e.clientId),
this.options.clientSecret &&
(this.api.headers['openpanel-client-secret'] =
this.options.clientSecret);
}
setProfileId(e) {
this.state.profileId = e;
}
setProfile(e) {
this.setProfileId(e.profileId),
this.api.fetch('/profile', {
...e,
properties: { ...this.state.properties, ...e.properties },
});
}
increment(e, i, t) {
const n = t?.profileId ?? this.state.profileId;
if (!n) {
return console.log('No profile id');
}
this.api.fetch('/profile/increment', {
profileId: n,
property: e,
value: i,
});
}
decrement(e, i, t) {
const n = t?.profileId ?? this.state.profileId;
if (!n) {
return console.log('No profile id');
}
this.api.fetch('/profile/decrement', {
profileId: n,
property: e,
value: i,
});
}
event(e, i) {
const t = i?.profileId ?? this.state.profileId;
delete i?.profileId,
this.api
.fetch('/event', {
name: e,
properties: { ...this.state.properties, ...(i ?? {}) },
timestamp: this.timestamp(),
deviceId: this.getDeviceId(),
profileId: t,
})
.then((n) => {
this.options.setDeviceId && n && this.options.setDeviceId(n);
});
}
setGlobalProperties(e) {
this.state.properties = { ...this.state.properties, ...e };
}
clear() {
(this.state.deviceId = void 0),
(this.state.profileId = void 0),
this.options.removeDeviceId && this.options.removeDeviceId();
}
timestamp() {
return new Date().toISOString();
}
getDeviceId() {
if (this.state.deviceId) {
return this.state.deviceId;
}
this.options.getDeviceId &&
(this.state.deviceId = this.options.getDeviceId() || void 0);
}
};
function b(r) {
return r.replace(/([-_][a-z])/gi, (e) =>
e.toUpperCase().replace('-', '').replace('_', '')
);
}
var f = class extends u {
constructor(i) {
super(i);
this.lastPath = '';
this.isServer() ||
(this.setGlobalProperties({ __referrer: document.referrer }),
this.options.trackOutgoingLinks && this.trackOutgoingLinks(),
this.options.trackScreenViews && this.trackScreenViews(),
this.options.trackAttributes && this.trackAttributes());
}
debounce(i, t) {
clearTimeout(this.debounceTimer), (this.debounceTimer = setTimeout(i, t));
}
isServer() {
return typeof document > 'u';
}
trackOutgoingLinks() {
this.isServer() ||
document.addEventListener('click', (i) => {
const t = i.target,
n = t.closest('a');
if (n && t) {
const s = n.getAttribute('href');
s?.startsWith('http') &&
super.event('link_out', {
href: s,
text:
n.innerText ||
n.getAttribute('title') ||
t.getAttribute('alt') ||
t.getAttribute('title'),
});
}
});
}
trackScreenViews() {
if (this.isServer()) {
return;
}
const i = history.pushState;
history.pushState = function (...o) {
const a = i.apply(this, o);
return (
window.dispatchEvent(new Event('pushstate')),
window.dispatchEvent(new Event('locationchange')),
a
);
};
const t = history.replaceState;
(history.replaceState = function (...o) {
const a = t.apply(this, o);
return (
window.dispatchEvent(new Event('replacestate')),
window.dispatchEvent(new Event('locationchange')),
a
);
}),
window.addEventListener('popstate', () => {
window.dispatchEvent(new Event('locationchange'));
});
const n = () => this.debounce(() => this.screenView(), 50);
this.options.hash
? window.addEventListener('hashchange', n)
: window.addEventListener('locationchange', n),
setTimeout(() => n(), 50);
}
trackAttributes() {
this.isServer() ||
document.addEventListener('click', (i) => {
const t = i.target,
n = t.closest('button'),
s = t.closest('a'),
o = n?.getAttribute('data-event')
? n
: s?.getAttribute('data-event')
? s
: null;
if (o) {
const a = {};
for (const c of o.attributes) {
c.name.startsWith('data-') &&
c.name !== 'data-event' &&
(a[b(c.name.replace(/^data-/, ''))] = c.value);
}
const p = o.getAttribute('data-event');
p && super.event(p, a);
}
});
}
screenView(i) {
if (this.isServer()) {
return;
}
const t = window.location.href;
this.lastPath !== t &&
((this.lastPath = t),
super.event('screen_view', {
...(i ?? {}),
__path: t,
__title: document.title,
}));
}
};
((r) => {
if (r.op && 'q' in r.op) {
const e = r.op.q || [],
i = new f(e.shift()[1]);
e.forEach((t) => {
t[0] in i && i[t[0]](...t.slice(1));
}),
(r.op = (t, ...n) => {
const s = i[t].bind(i);
typeof s == 'function' && s(...n);
});
}
})(window);
})();
//# sourceMappingURL=cdn.global.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More