Compare commits
25 Commits
feature/gr
...
8ca774ad1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ca774ad1c
|
|||
|
53c989462a
|
|||
|
58414f1035
|
|||
|
31fbe0a809
|
|||
|
1b23fee108
|
|||
|
3043a9cdd1
|
|||
|
655ea1f87e
|
|||
|
fcb4cf5fb0
|
|||
|
9b197abcfa
|
|||
|
|
a1ce71ffb6 | ||
|
|
20665789e1 | ||
|
|
2fb993fae5 | ||
|
|
b467a6ce7f | ||
|
|
b88b2844b3 | ||
|
|
ddc1b75b58 | ||
|
|
7239c59342 | ||
|
|
a82069c28c | ||
|
|
bca07ae0d7 | ||
|
|
21e51daa5f | ||
|
|
729722bf85 | ||
|
|
a8481a213f | ||
|
|
6287cb7958 | ||
|
|
ebc07e3a16 | ||
|
|
11e9ecac1a | ||
|
|
88a2d876ce |
55
.gitea/workflows/docker-build-api.yml
Normal file
55
.gitea/workflows/docker-build-api.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build and Push API
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.zias.be
|
||||
OWNER: zias
|
||||
|
||||
jobs:
|
||||
build-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/api/Dockerfile
|
||||
target: runner
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api:buildcache
|
||||
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-api:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||
build-args: |-
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
53
.gitea/workflows/docker-build-dashboard.yml
Normal file
53
.gitea/workflows/docker-build-dashboard.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Build and Push Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.zias.be
|
||||
OWNER: zias
|
||||
|
||||
jobs:
|
||||
build-dashboard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/start/Dockerfile
|
||||
target: runner
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard:buildcache
|
||||
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-dashboard:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||
55
.gitea/workflows/docker-build-worker.yml
Normal file
55
.gitea/workflows/docker-build-worker.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build and Push Worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.zias.be
|
||||
OWNER: zias
|
||||
|
||||
jobs:
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/worker/Dockerfile
|
||||
target: runner
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker:buildcache
|
||||
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-worker:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||
build-args: |-
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
@@ -47,4 +47,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,4 @@ You can now access the following:
|
||||
- Bullboard (queue): http://localhost:9999
|
||||
- `pnpm dock:ch` to access clickhouse terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
---
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"types": ["node"],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ FROM node:${NODE_VERSION}-slim AS base
|
||||
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
|
||||
ENV COREPACK_INTEGRITY_KEYS=0
|
||||
|
||||
RUN corepack enable && apt-get update && \
|
||||
RUN rm -f /usr/local/bin/pnpm /usr/local/bin/pnpx && npm install -g pnpm@10.6.2 && apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
|
||||
@@ -65,4 +65,4 @@
|
||||
"tsdown": "0.14.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
@@ -63,6 +62,7 @@ async function main() {
|
||||
imported_at: null,
|
||||
sdk_name: 'test-script',
|
||||
sdk_version: '1.0.0',
|
||||
groups: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,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]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import { eventBuffer } from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import {
|
||||
psubscribeToPublishedEvent,
|
||||
subscribeToPublishedEvent,
|
||||
} from '@openpanel/redis';
|
||||
import { subscribeToPublishedEvent } from '@openpanel/redis';
|
||||
import { getProjectAccess } from '@openpanel/trpc';
|
||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
@@ -39,19 +36,8 @@ export function wsVisitors(
|
||||
}
|
||||
);
|
||||
|
||||
const punsubscribe = psubscribeToPublishedEvent(
|
||||
'__keyevent@0__:expired',
|
||||
(key) => {
|
||||
const [, , projectId] = key.split(':');
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => {
|
||||
unsubscribe();
|
||||
punsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
68
apps/api/src/controllers/logs.controller.ts
Normal file
68
apps/api/src/controllers/logs.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { type LogsQueuePayload, logsQueue } from '@openpanel/queue';
|
||||
import { type ILogBatchPayload, zLogBatchPayload } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getDeviceId } from '@/utils/ids';
|
||||
import { getStringHeaders } from './track.controller';
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{ Body: ILogBatchPayload }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send({ status: 400, error: 'Missing projectId' });
|
||||
}
|
||||
|
||||
const validationResult = zLogBatchPayload.safeParse(request.body);
|
||||
if (!validationResult.success) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Validation failed',
|
||||
errors: validationResult.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { logs } = validationResult.data;
|
||||
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||
const headers = getStringHeaders(request.headers);
|
||||
const receivedAt = new Date().toISOString();
|
||||
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
const { deviceId, sessionId } = await getDeviceId({ projectId, ip, ua, salts });
|
||||
const uaInfo = parseUserAgent(ua, undefined);
|
||||
|
||||
const jobs: LogsQueuePayload[] = logs.map((log) => ({
|
||||
type: 'incomingLog' as const,
|
||||
payload: {
|
||||
projectId,
|
||||
log: {
|
||||
...log,
|
||||
timestamp: log.timestamp ?? receivedAt,
|
||||
},
|
||||
uaInfo,
|
||||
geo: {
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
},
|
||||
headers,
|
||||
deviceId,
|
||||
sessionId,
|
||||
},
|
||||
}));
|
||||
|
||||
await logsQueue.addBulk(
|
||||
jobs.map((job) => ({
|
||||
name: 'incomingLog',
|
||||
data: job,
|
||||
})),
|
||||
);
|
||||
|
||||
return reply.status(200).send({ ok: true, count: logs.length });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import {
|
||||
getProfileById,
|
||||
getSalts,
|
||||
groupBuffer,
|
||||
replayBuffer,
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import {
|
||||
type IAssignGroupPayload,
|
||||
type IDecrementPayload,
|
||||
type IGroupPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type IReplayPayload,
|
||||
@@ -218,6 +221,7 @@ async function handleTrack(
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
groups: payload.groups ?? [],
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
@@ -333,6 +337,36 @@ async function handleReplay(
|
||||
await replayBuffer.add(row);
|
||||
}
|
||||
|
||||
async function handleGroup(
|
||||
payload: IGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { id, type, name, properties = {} } = payload;
|
||||
await groupBuffer.add({
|
||||
id,
|
||||
projectId: context.projectId,
|
||||
type,
|
||||
name,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAssignGroup(
|
||||
payload: IAssignGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const profileId = payload.profileId ?? context.deviceId;
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
await upsertProfile({
|
||||
id: String(profileId),
|
||||
projectId: context.projectId,
|
||||
isExternal: !!payload.profileId,
|
||||
groups: payload.groupIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
@@ -381,6 +415,12 @@ export async function handler(
|
||||
case 'replay':
|
||||
await handleReplay(validatedBody.payload, context);
|
||||
break;
|
||||
case 'group':
|
||||
await handleGroup(validatedBody.payload, context);
|
||||
break;
|
||||
case 'assign_group':
|
||||
await handleAssignGroup(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -40,6 +40,7 @@ import gscCallbackRouter from './routes/gsc-callback.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import logsRouter from './routes/logs.router';
|
||||
import manageRouter from './routes/manage.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
@@ -198,6 +199,7 @@ const startServer = async () => {
|
||||
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||
instance.register(miscRouter, { prefix: '/misc' });
|
||||
instance.register(aiRouter, { prefix: '/ai' });
|
||||
instance.register(logsRouter, { prefix: '/logs' });
|
||||
});
|
||||
|
||||
// Public API
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
6
apps/api/src/routes/logs.router.ts
Normal file
6
apps/api/src/routes/logs.router.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { handler } from '@/controllers/logs.controller';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export default async function (fastify: FastifyInstance) {
|
||||
fastify.post('/', handler);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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}\`
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ export async function isDuplicatedEvent({
|
||||
origin,
|
||||
projectId,
|
||||
},
|
||||
'md5',
|
||||
'md5'
|
||||
)}`,
|
||||
'1',
|
||||
100,
|
||||
100
|
||||
);
|
||||
|
||||
if (locked) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import type { Options } from 'tsdown';
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
const options: Options = {
|
||||
clean: true,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -549,4 +549,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -47,7 +42,7 @@
|
||||
"Large enterprises with dedicated analytics teams",
|
||||
"Organizations that need advanced experimentation and feature flags",
|
||||
"Teams requiring sophisticated behavioral cohorts and predictive analytics",
|
||||
"Companies wanting an all-in-one platform with session replay and guides"
|
||||
"Companies wanting an all-in-one platform with guides, surveys, and advanced experimentation"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
@@ -184,9 +179,9 @@
|
||||
},
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Included in Amplitude platform"
|
||||
"notes": "Both platforms include session replay"
|
||||
},
|
||||
{
|
||||
"name": "Custom dashboards",
|
||||
@@ -423,7 +418,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Simpler analytics needs",
|
||||
"description": "If you don't need predictive ML models, feature flags, or session replay, OpenPanel gives you core analytics without the bloat.",
|
||||
"description": "If you don't need predictive ML models or feature flags, OpenPanel gives you core analytics — including session replay — without the enterprise bloat.",
|
||||
"icon": "target"
|
||||
}
|
||||
]
|
||||
@@ -484,7 +479,7 @@
|
||||
},
|
||||
{
|
||||
"question": "What Amplitude features will I lose?",
|
||||
"answer": "OpenPanel doesn't have feature flags, session replay, predictive cohorts, or the Guides & Surveys product. If you rely heavily on these enterprise features, Amplitude may still be the better fit."
|
||||
"answer": "OpenPanel doesn't have feature flags, predictive cohorts, or the Guides & Surveys product. OpenPanel does include session replay. If you rely heavily on Amplitude's enterprise experimentation or ML-powered features, Amplitude may still be the better fit."
|
||||
},
|
||||
{
|
||||
"question": "How does the SDK size affect my app?",
|
||||
@@ -562,4 +557,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,4 +468,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,4 +520,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:`);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Remove FullStory script",
|
||||
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to session replay and heatmaps."
|
||||
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to FullStory's advanced heatmaps, frustration signals, and pixel-perfect replay. OpenPanel includes basic session replay."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
@@ -468,4 +468,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,4 +514,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,4 +588,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,4 +587,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,4 +472,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,4 +533,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Enterprise teams needing advanced experimentation and feature flags",
|
||||
"Organizations requiring session replay across web and mobile",
|
||||
"Teams needing Metric Trees for organizational goal alignment",
|
||||
"Companies with complex data warehouse integration needs",
|
||||
"Teams that need Metric Trees for organizational alignment"
|
||||
]
|
||||
@@ -184,9 +184,15 @@
|
||||
},
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Mixpanel supports web, iOS, and Android"
|
||||
"notes": "Mixpanel supports web, iOS, and Android. OpenPanel also offers session replay."
|
||||
},
|
||||
{
|
||||
"name": "Group analytics",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support group/company-level analytics"
|
||||
},
|
||||
{
|
||||
"name": "Revenue tracking",
|
||||
@@ -441,7 +447,7 @@
|
||||
"items": [
|
||||
{
|
||||
"question": "Does OpenPanel have all the features I use in Mixpanel?",
|
||||
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, and A/B testing. If you rely heavily on Mixpanel's session replay, feature flags, or Metric Trees, those aren't available in OpenPanel yet."
|
||||
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, A/B testing, session replay, and group analytics. If you rely heavily on Mixpanel's feature flags or Metric Trees, those aren't available in OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Can I import my historical Mixpanel data?",
|
||||
@@ -519,4 +525,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@
|
||||
"features": [
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
"notes": "Mouseflow's session replay is more advanced with friction scoring and form analytics"
|
||||
},
|
||||
{
|
||||
"name": "Click heatmaps",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,4 +514,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,4 +519,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"title": "Why consider OpenPanel over PostHog?",
|
||||
"paragraphs": [
|
||||
"PostHog has built an impressive all-in-one platform with product analytics, feature flags, session replay, surveys, A/B testing, and more \u2014 over 10 products under one roof. It's a popular choice among developer-led teams who want everything in a single tool. But that breadth comes with trade-offs: a 52+ KB SDK, complex multi-product pricing, and a self-hosted setup that requires ClickHouse, Kafka, Redis, and PostgreSQL.",
|
||||
"OpenPanel takes a focused approach. Instead of trying to be everything, it delivers excellent analytics \u2014 events, funnels, retention, cohorts, user profiles, and web analytics \u2014 with a dramatically smaller footprint. The SDK is just 2.3 KB (over 20x lighter than PostHog), which directly translates to faster page loads and better Core Web Vitals for your users.",
|
||||
"OpenPanel takes a focused approach. Instead of trying to be everything, it delivers excellent analytics \u2014 events, funnels, retention, cohorts, user profiles, session replay, and web analytics \u2014 with a dramatically smaller footprint. The SDK is just 2.3 KB (over 20x lighter than PostHog), which directly translates to faster page loads and better Core Web Vitals for your users.",
|
||||
"Cookie-free tracking is another key difference. PostHog uses cookies by default and requires configuration to go cookieless, while OpenPanel is cookie-free out of the box \u2014 no consent banners needed. Self-hosting is also far simpler: OpenPanel runs in a single Docker container compared to PostHog's multi-service architecture.",
|
||||
"If you need focused analytics without the feature bloat, want a lighter SDK that doesn't impact performance, and prefer simple event-based pricing over multi-product metering \u2014 OpenPanel gives you exactly what you need without the overhead."
|
||||
]
|
||||
@@ -38,13 +38,13 @@
|
||||
"intro": "Both are open-source analytics platforms. PostHog is an all-in-one platform with many products. OpenPanel focuses on analytics with simplicity.",
|
||||
"one_liner": "PostHog is an all-in-one platform with 10+ products; OpenPanel focuses on analytics with a lighter footprint.",
|
||||
"best_for_openpanel": [
|
||||
"Teams wanting focused analytics without feature flags, session replay, or surveys",
|
||||
"Teams wanting focused analytics without feature flags or surveys",
|
||||
"Privacy-conscious products needing cookie-free tracking by default",
|
||||
"Performance-conscious applications (2.3KB SDK vs 52KB+)",
|
||||
"Teams preferring simple Docker deployment over multi-service architecture"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams needing all-in-one platform (analytics, feature flags, session replay, surveys)",
|
||||
"Teams needing all-in-one platform (analytics, feature flags, surveys, A/B experiments)",
|
||||
"Developers wanting SQL access (HogQL) for custom queries",
|
||||
"Y Combinator companies leveraging PostHog's ecosystem",
|
||||
"Teams requiring extensive CDP capabilities with 60+ connectors"
|
||||
@@ -176,9 +176,9 @@
|
||||
},
|
||||
{
|
||||
"name": "Session Replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "PostHog includes session replay for web, Android (beta), iOS (alpha)"
|
||||
"notes": "Both platforms offer session replay."
|
||||
},
|
||||
{
|
||||
"name": "Surveys",
|
||||
@@ -391,7 +391,7 @@
|
||||
"items": [
|
||||
{
|
||||
"title": "Teams Who Want Analytics Without Feature Bloat",
|
||||
"description": "If you need product analytics but don't use PostHog's feature flags, session replay, surveys, or experiments, OpenPanel gives you exactly what you need without the overhead.",
|
||||
"description": "If you need product analytics and session replay but don't need PostHog's feature flags, surveys, or experiments, OpenPanel gives you exactly what you need without the overhead.",
|
||||
"icon": "target"
|
||||
},
|
||||
{
|
||||
@@ -430,7 +430,7 @@
|
||||
},
|
||||
{
|
||||
"question": "What features will I lose switching from PostHog?",
|
||||
"answer": "PostHog includes feature flags, session replay, surveys, and A/B experiments in their platform. If you actively use these, you'd need separate tools. If you primarily use PostHog for analytics, OpenPanel provides everything you need with less complexity."
|
||||
"answer": "PostHog includes feature flags, surveys, and A/B experiments in their platform. If you actively use these, you'd need separate tools. OpenPanel now includes session replay, so you won't lose that. If you primarily use PostHog for analytics, OpenPanel provides everything you need with less complexity."
|
||||
},
|
||||
{
|
||||
"question": "How does OpenPanel compare on privacy?",
|
||||
@@ -442,7 +442,7 @@
|
||||
},
|
||||
{
|
||||
"question": "Is PostHog more feature-rich than OpenPanel?",
|
||||
"answer": "PostHog offers more products (10+ including feature flags, session replay, surveys, A/B testing, data warehouse). However, this comes with added complexity. OpenPanel focuses on doing analytics exceptionally well with a simpler, more focused experience."
|
||||
"answer": "PostHog offers more products (10+ including feature flags, surveys, A/B testing, data warehouse). However, this comes with added complexity. OpenPanel now includes session replay alongside its core analytics, while staying focused on simplicity and performance."
|
||||
},
|
||||
{
|
||||
"question": "How do SDK sizes compare?",
|
||||
|
||||
@@ -518,4 +518,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "5 Best Smartlook Alternatives in 2026 (Free & Open Source)",
|
||||
"description": "Replace Smartlook's session recording with OpenPanel — cookie-free product analytics with events, funnels, and retention. Open source, self-hostable, and no consent banners required.",
|
||||
"description": "Looking for a Smartlook alternative? OpenPanel is open source with product analytics, session replay, funnels, and retention. Self-hostable, cookie-free, and no consent banners required.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Best Smartlook Alternative",
|
||||
"subheading": "Need product analytics without requiring session replay? OpenPanel is an open-source alternative to Smartlook that focuses on event-based analytics, funnels, and retention\u2014with self-hosting and transparent pricing.",
|
||||
"subheading": "OpenPanel is an open-source alternative to Smartlook with event-based product analytics, session replay, funnels, and retention\u2014with self-hosting, transparent pricing, and no Cisco vendor lock-in.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Self-hostable",
|
||||
@@ -28,28 +28,27 @@
|
||||
"title": "Why consider OpenPanel over Smartlook?",
|
||||
"paragraphs": [
|
||||
"Smartlook combines product analytics with visual insights \u2014 session recordings, heatmaps, and event tracking in one platform. Since its acquisition by Cisco in 2023, it has positioned itself as an enterprise-ready analytics and observation tool. But enterprise ownership often means enterprise pricing, proprietary lock-in, and cloud-only infrastructure with no option for self-hosting.",
|
||||
"OpenPanel focuses purely on product analytics without the session replay overhead, delivering event tracking, funnels, retention analysis, and cohort breakdowns with a cleaner, more focused experience. The result is a lighter tool that does analytics well rather than trying to be everything \u2014 and at a dramatically lower cost with transparent, event-based pricing starting at $2.50 per month.",
|
||||
"OpenPanel delivers event tracking, funnels, retention analysis, cohort breakdowns, and session replay in a focused, open-source package. The result is a tool that covers both product analytics and visual session review \u2014 at a dramatically lower cost with transparent, event-based pricing starting at $2.50 per month.",
|
||||
"Being open source under the MIT license gives OpenPanel advantages that Smartlook's proprietary, Cisco-owned platform can't match. You can self-host on your own infrastructure for complete data sovereignty, audit the source code for security compliance, and avoid the vendor lock-in risk that comes with acquisition-prone platforms. Self-hosting also means unlimited data retention, compared to Smartlook's plan-based limits.",
|
||||
"If you need session replay specifically, Smartlook has the edge in that area. But for teams that want focused, cost-effective product analytics with open-source transparency and the freedom to self-host, OpenPanel delivers more value without the enterprise complexity."
|
||||
"If you need advanced heatmaps or Unity/game analytics, Smartlook has the edge. But for teams that want product analytics plus session replay with open-source transparency, self-hosting, and predictable pricing, OpenPanel delivers more value without the Cisco enterprise complexity."
|
||||
]
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Smartlook: Which is right for you?",
|
||||
"intro": "Both platforms offer product analytics, but Smartlook adds visual behavior tools (session replay, heatmaps) while OpenPanel focuses on event-based analytics with self-hosting.",
|
||||
"one_liner": "OpenPanel is open source with self-hosting for product analytics; Smartlook combines analytics with session replay and heatmaps.",
|
||||
"intro": "Both platforms offer product analytics and session replay. Smartlook adds heatmaps and frustration signals; OpenPanel adds self-hosting, open source, and simpler pricing.",
|
||||
"one_liner": "OpenPanel is open source with self-hosting, product analytics, and session replay; Smartlook adds heatmaps and deeper visual behavior tools.",
|
||||
"best_for_openpanel": [
|
||||
"Teams needing self-hosting for data ownership and compliance",
|
||||
"Open source requirements for transparency",
|
||||
"Focus on event-based product analytics without visual replay",
|
||||
"Open source requirements for transparency and auditability",
|
||||
"Product analytics plus session replay without Cisco vendor lock-in",
|
||||
"Teams wanting unlimited data retention with self-hosting",
|
||||
"Server-side SDKs for backend tracking"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams needing session recordings to watch user interactions",
|
||||
"UX designers requiring heatmaps (click, scroll, movement)",
|
||||
"UX designers requiring comprehensive heatmaps (click, scroll, movement)",
|
||||
"Mobile app crash reports with linked session recordings",
|
||||
"Teams wanting combined analytics and replay in one tool",
|
||||
"Unity game developers (Smartlook supports Unity)"
|
||||
"Teams needing Unity game analytics",
|
||||
"Teams requiring Cisco/AppDynamics ecosystem integration"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
@@ -68,8 +67,8 @@
|
||||
},
|
||||
{
|
||||
"label": "Session replay",
|
||||
"openpanel": "Not available",
|
||||
"competitor": "Yes, full recordings"
|
||||
"openpanel": "Yes",
|
||||
"competitor": "Yes, with heatmaps & friction detection"
|
||||
},
|
||||
{
|
||||
"label": "Heatmaps",
|
||||
@@ -139,9 +138,9 @@
|
||||
"features": [
|
||||
{
|
||||
"name": "Session recordings",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
"notes": "Smartlook additionally links recordings to crash reports and heatmaps"
|
||||
},
|
||||
{
|
||||
"name": "Click heatmaps",
|
||||
@@ -311,13 +310,13 @@
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Smartlook to OpenPanel",
|
||||
"intro": "Moving from Smartlook to OpenPanel involves transitioning from combined session replay and analytics to event-based product analytics.",
|
||||
"intro": "Moving from Smartlook to OpenPanel means keeping session replay and product analytics while gaining self-hosting, open source, and simpler pricing.",
|
||||
"difficulty": "moderate",
|
||||
"estimated_time": "2-4 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Understand feature differences",
|
||||
"description": "OpenPanel focuses on event-based product analytics. If you rely on session recordings and heatmaps, consider using complementary tools like Microsoft Clarity."
|
||||
"description": "OpenPanel includes session replay and event-based product analytics. If you rely on heatmaps or Unity analytics, consider using complementary tools like Microsoft Clarity for heatmaps."
|
||||
},
|
||||
{
|
||||
"title": "Create OpenPanel account or self-host",
|
||||
@@ -382,11 +381,11 @@
|
||||
"items": [
|
||||
{
|
||||
"question": "Can OpenPanel replace Smartlook's session recordings?",
|
||||
"answer": "No, OpenPanel does not provide session recordings or heatmaps. If you need visual behavior analytics, consider using Microsoft Clarity (free) or Hotjar alongside OpenPanel, or continue using Smartlook for recordings while using OpenPanel for deeper product analytics."
|
||||
"answer": "Yes for session replay — OpenPanel now includes session recording. However, if you need heatmaps (click, scroll, movement), frustration signals, or Unity game analytics, Smartlook still has the edge in those areas."
|
||||
},
|
||||
{
|
||||
"question": "Which tool has better funnel analysis?",
|
||||
"answer": "Both tools offer funnel analysis. Smartlook's advantage is the ability to watch session recordings of users who dropped off. OpenPanel offers more advanced funnel customization and cohort breakdowns."
|
||||
"answer": "Both tools offer funnel analysis. With OpenPanel you can also watch session recordings of users who dropped off, and OpenPanel offers more advanced funnel customization and cohort breakdowns."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host Smartlook?",
|
||||
@@ -448,4 +447,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,4 +548,4 @@
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
{
|
||||
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
|
||||
"pages": [
|
||||
"sdks",
|
||||
"how-it-works",
|
||||
"session-replay",
|
||||
"consent-management",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,6 +68,34 @@ app.listen(3000, () => {
|
||||
- `trackRequest` - A function that returns `true` if the request should be tracked.
|
||||
- `getProfileId` - A function that returns the profile ID of the user making the request.
|
||||
|
||||
## Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. Since Express is a backend SDK, you can upsert groups and assign users from your route handlers.
|
||||
|
||||
See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
```ts
|
||||
app.post('/login', async (req, res) => {
|
||||
const user = await loginUser(req.body);
|
||||
|
||||
// Identify the user
|
||||
req.op.identify({ profileId: user.id, email: user.email });
|
||||
|
||||
// Create/update the group entity
|
||||
req.op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan },
|
||||
});
|
||||
|
||||
// Assign the user to the group
|
||||
req.op.setGroup(user.organizationId);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Typescript
|
||||
|
||||
If `req.op` is not typed you can extend the `Request` interface.
|
||||
|
||||
@@ -116,9 +116,38 @@ op.decrement({
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
**Create or update a group:**
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'enterprise' },
|
||||
});
|
||||
```
|
||||
|
||||
**Assign the current user to a group** (call after `identify`):
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.setGroup('org_acme');
|
||||
// or multiple groups:
|
||||
op.setGroups(['org_acme', 'team_eng']);
|
||||
```
|
||||
|
||||
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
@@ -227,9 +227,32 @@ useOpenPanel().decrement({
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
**Create or update a group:**
|
||||
|
||||
```tsx title="app/login/page.tsx"
|
||||
useOpenPanel().upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'enterprise' },
|
||||
});
|
||||
```
|
||||
|
||||
**Assign the current user to a group** (call after `identify`):
|
||||
|
||||
```tsx title="app/login/page.tsx"
|
||||
useOpenPanel().setGroup('org_acme');
|
||||
```
|
||||
|
||||
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```js title="index.js"
|
||||
useOpenPanel().clear()
|
||||
|
||||
@@ -120,3 +120,35 @@ op.track('my_event', { foo: 'bar' });
|
||||
</Tabs>
|
||||
|
||||
For more information on how to use the SDK, check out the [Javascript SDK](/docs/sdks/javascript#usage).
|
||||
|
||||
## Offline support
|
||||
|
||||
The SDK can buffer events when the device is offline and flush them once connectivity is restored. Events are stamped with a `__timestamp` at the time they are fired so they are recorded with the correct time even if they are delivered later.
|
||||
|
||||
Two optional peer dependencies enable this feature:
|
||||
|
||||
```npm
|
||||
npm install @react-native-async-storage/async-storage @react-native-community/netinfo
|
||||
```
|
||||
|
||||
Pass them to the constructor:
|
||||
|
||||
```typescript
|
||||
import { OpenPanel } from '@openpanel/react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
const op = new OpenPanel({
|
||||
clientId: '{YOUR_CLIENT_ID}',
|
||||
clientSecret: '{YOUR_CLIENT_SECRET}',
|
||||
// Persist the event queue across app restarts
|
||||
storage: AsyncStorage,
|
||||
// Automatically flush the queue when the device comes back online
|
||||
networkInfo: NetInfo,
|
||||
});
|
||||
```
|
||||
|
||||
Both options are independent — you can use either one or both:
|
||||
|
||||
- **`storage`** — persists the queue to disk so events survive app restarts while offline.
|
||||
- **`networkInfo`** — flushes the queue automatically when connectivity is restored. Without this, the queue is flushed the next time the app becomes active.
|
||||
|
||||
@@ -174,9 +174,37 @@ function MyComponent() {
|
||||
}
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function LoginComponent() {
|
||||
const handleLogin = async (user: User) => {
|
||||
// 1. Identify the user
|
||||
op.identify({ profileId: user.id, email: user.email });
|
||||
|
||||
// 2. Create/update the group entity (only when data changes)
|
||||
op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan },
|
||||
});
|
||||
|
||||
// 3. Link the user to their group — tags all future events
|
||||
op.setGroup(user.organizationId);
|
||||
};
|
||||
|
||||
return <button onClick={() => handleLogin(user)}>Login</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
@@ -106,6 +106,81 @@ curl -X POST https://api.openpanel.dev/track \
|
||||
}'
|
||||
```
|
||||
|
||||
### Creating or updating a group
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "group",
|
||||
"payload": {
|
||||
"id": "org_acme",
|
||||
"type": "company",
|
||||
"name": "Acme Inc",
|
||||
"properties": {
|
||||
"plan": "enterprise",
|
||||
"seats": 25
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | Yes | Unique identifier for the group |
|
||||
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
|
||||
| `name` | `string` | Yes | Display name |
|
||||
| `properties` | `object` | No | Custom metadata |
|
||||
|
||||
### Assigning a user to a group
|
||||
|
||||
Links a profile to one or more groups. This updates the profile record but does not auto-attach groups to future events — you still need to pass `groups` explicitly on each track call.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "assign_group",
|
||||
"payload": {
|
||||
"profileId": "user_123",
|
||||
"groupIds": ["org_acme"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `profileId` | `string` | No | Profile to assign. Falls back to the device ID if omitted |
|
||||
| `groupIds` | `string[]` | Yes | Group IDs to link to the profile |
|
||||
|
||||
### Tracking events with groups
|
||||
|
||||
Groups are never auto-populated on events — even if the profile has been assigned to a group via `assign_group`. Pass `groups` on every track event where you want group data.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "track",
|
||||
"payload": {
|
||||
"name": "report_exported",
|
||||
"profileId": "user_123",
|
||||
"groups": ["org_acme"],
|
||||
"properties": {
|
||||
"format": "pdf"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Unlike the SDK, where `setGroup()` stores group IDs on the instance and attaches them to every subsequent `track()` call, the API has no such state. You must pass `groups` on each event.
|
||||
|
||||
### Error Handling
|
||||
The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error.
|
||||
Example error response:
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"title": "Dashboard",
|
||||
"pages": [
|
||||
"understand-the-overview",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
"pages": ["understand-the-overview", "..."]
|
||||
}
|
||||
|
||||
@@ -59,7 +59,16 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
|
||||
|
||||
## Insights
|
||||
|
||||
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
|
||||
A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed.
|
||||
|
||||
Each card shows:
|
||||
- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
|
||||
- **Absolute change**: The raw increase or decrease in sessions compared to the previous period
|
||||
- **Percentage change**: How much that property grew or declined relative to its own previous value
|
||||
|
||||
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
|
||||
|
||||
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
|
||||
|
||||
---
|
||||
|
||||
|
||||
208
apps/public/content/docs/get-started/groups.mdx
Normal file
208
apps/public/content/docs/get-started/groups.mdx
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Groups
|
||||
description: Track analytics at the account, company, or team level — not just individual users.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
|
||||
|
||||
This is especially useful for B2B SaaS products where a single paying account has many users.
|
||||
|
||||
## How Groups work
|
||||
|
||||
There are two separate concepts:
|
||||
|
||||
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
|
||||
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
|
||||
|
||||
## Creating or updating a group
|
||||
|
||||
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
|
||||
|
||||
```typescript
|
||||
op.upsertGroup({
|
||||
id: 'org_acme', // Your group's unique ID
|
||||
type: 'company', // Group type (company, workspace, team, etc.)
|
||||
name: 'Acme Inc', // Display name
|
||||
properties: {
|
||||
plan: 'enterprise',
|
||||
seats: 25,
|
||||
industry: 'logistics',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Group payload
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | Yes | Unique identifier for the group |
|
||||
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
|
||||
| `name` | `string` | Yes | Human-readable display name |
|
||||
| `properties` | `object` | No | Custom metadata about the group |
|
||||
|
||||
## Managing groups in the dashboard
|
||||
|
||||
The easiest way to create, edit, and delete groups is directly in the OpenPanel dashboard. Navigate to your project and open the **Groups** section — from there you can manage group names, types, and properties without touching any code.
|
||||
|
||||
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
|
||||
|
||||
<Callout>
|
||||
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
|
||||
</Callout>
|
||||
|
||||
## Assigning a user to a group
|
||||
|
||||
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
|
||||
|
||||
```typescript
|
||||
// After login
|
||||
op.identify({ profileId: 'user_123' });
|
||||
|
||||
// Link the user to their organization
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
For users that belong to multiple groups:
|
||||
|
||||
```typescript
|
||||
op.setGroups(['org_acme', 'team_engineering']);
|
||||
```
|
||||
|
||||
<Callout>
|
||||
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
|
||||
</Callout>
|
||||
|
||||
## Full login flow example
|
||||
|
||||
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
|
||||
|
||||
```typescript
|
||||
// 1. Identify the user
|
||||
op.identify({
|
||||
profileId: 'user_123',
|
||||
firstName: 'Jane',
|
||||
email: 'jane@acme.com',
|
||||
});
|
||||
|
||||
// 2. Assign the user to the group — the group doesn't need to exist yet
|
||||
op.setGroup('org_acme');
|
||||
|
||||
// 3. All subsequent events are now tagged with the group
|
||||
op.track('dashboard_viewed'); // → includes groups: ['org_acme']
|
||||
op.track('report_exported'); // → includes groups: ['org_acme']
|
||||
```
|
||||
|
||||
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
|
||||
|
||||
```typescript
|
||||
op.identify({ profileId: 'user_123', email: 'jane@acme.com' });
|
||||
|
||||
// Sync group metadata from your backend
|
||||
op.upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'pro' },
|
||||
});
|
||||
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
## Per-event group override
|
||||
|
||||
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
|
||||
|
||||
```typescript
|
||||
op.track('file_shared', {
|
||||
filename: 'q4-report.pdf',
|
||||
groups: ['org_acme', 'org_partner'], // Only applies to this event
|
||||
});
|
||||
```
|
||||
|
||||
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
|
||||
|
||||
## Clearing groups on logout
|
||||
|
||||
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
|
||||
|
||||
```typescript
|
||||
function handleLogout() {
|
||||
op.clear();
|
||||
// redirect to login...
|
||||
}
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### B2B SaaS — company accounts
|
||||
|
||||
```typescript
|
||||
// On login
|
||||
op.identify({ profileId: user.id, email: user.email });
|
||||
op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan, mrr: user.mrr },
|
||||
});
|
||||
op.setGroup(user.organizationId);
|
||||
```
|
||||
|
||||
### Multi-tenant — workspaces
|
||||
|
||||
```typescript
|
||||
// When user switches workspace
|
||||
op.upsertGroup({
|
||||
id: workspace.id,
|
||||
type: 'workspace',
|
||||
name: workspace.name,
|
||||
});
|
||||
op.setGroup(workspace.id);
|
||||
```
|
||||
|
||||
### Teams within a company
|
||||
|
||||
```typescript
|
||||
// User belongs to a company and a specific team
|
||||
op.setGroups([user.organizationId, user.teamId]);
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
### `upsertGroup(payload)`
|
||||
|
||||
Creates the group if it doesn't exist, or merges properties into the existing group.
|
||||
|
||||
```typescript
|
||||
op.upsertGroup({
|
||||
id: string; // Required
|
||||
type: string; // Required
|
||||
name: string; // Required
|
||||
properties?: Record<string, unknown>;
|
||||
});
|
||||
```
|
||||
|
||||
### `setGroup(groupId)`
|
||||
|
||||
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
|
||||
|
||||
```typescript
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
### `setGroups(groupIds)`
|
||||
|
||||
Same as `setGroup()` but for multiple group IDs at once.
|
||||
|
||||
```typescript
|
||||
op.setGroups(['org_acme', 'team_engineering']);
|
||||
```
|
||||
|
||||
## What to avoid
|
||||
|
||||
- **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
|
||||
- **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
|
||||
- **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
|
||||
- **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.
|
||||
@@ -3,6 +3,7 @@
|
||||
"install-openpanel",
|
||||
"track-events",
|
||||
"identify-users",
|
||||
"groups",
|
||||
"revenue-tracking"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
"---Migration---",
|
||||
"...migration"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
251
apps/public/content/docs/self-hosting/high-volume.mdx
Normal file
251
apps/public/content/docs/self-hosting/high-volume.mdx
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: High volume setup
|
||||
description: Tuning OpenPanel for high event throughput
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
The default Docker Compose setup works well for most deployments. When you start seeing high event throughput — thousands of events per second or dozens of worker replicas — a few things need adjusting.
|
||||
|
||||
## Connection pooling with PGBouncer
|
||||
|
||||
PostgreSQL has a hard limit on the number of open connections. Each worker and API replica opens its own pool of connections, so the total can grow fast. Without pooling, you will start seeing `too many connections` errors under load.
|
||||
|
||||
PGBouncer sits in front of PostgreSQL and maintains a small pool of real database connections, multiplexing many application connections on top of them.
|
||||
|
||||
### Add PGBouncer to docker-compose.yml
|
||||
|
||||
Add the `op-pgbouncer` service and update the `op-api` and `op-worker` dependencies:
|
||||
|
||||
```yaml
|
||||
op-pgbouncer:
|
||||
image: edoburu/pgbouncer:v1.25.1-p0
|
||||
restart: always
|
||||
depends_on:
|
||||
op-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DB_HOST=op-db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_NAME=postgres
|
||||
- AUTH_TYPE=scram-sha-256
|
||||
- POOL_MODE=transaction
|
||||
- MAX_CLIENT_CONN=1000
|
||||
- DEFAULT_POOL_SIZE=20
|
||||
- MIN_POOL_SIZE=5
|
||||
- RESERVE_POOL_SIZE=5
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "PGPASSWORD=postgres psql -h 127.0.0.1 -p 5432 -U postgres pgbouncer -c 'SHOW VERSION;' -q || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
Then update `op-api` and `op-worker` to depend on `op-pgbouncer` instead of `op-db`:
|
||||
|
||||
```yaml
|
||||
op-api:
|
||||
depends_on:
|
||||
op-pgbouncer:
|
||||
condition: service_healthy
|
||||
op-ch:
|
||||
condition: service_healthy
|
||||
op-kv:
|
||||
condition: service_healthy
|
||||
|
||||
op-worker:
|
||||
depends_on:
|
||||
op-pgbouncer:
|
||||
condition: service_healthy
|
||||
op-api:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
### Update DATABASE_URL
|
||||
|
||||
Prisma needs to know it is talking to a pooler. Point `DATABASE_URL` at `op-pgbouncer` and add `&pgbouncer=true`:
|
||||
|
||||
```bash
|
||||
# Before
|
||||
DATABASE_URL=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
|
||||
|
||||
# After
|
||||
DATABASE_URL=postgresql://postgres:postgres@op-pgbouncer:5432/postgres?schema=public&pgbouncer=true
|
||||
```
|
||||
|
||||
Leave `DATABASE_URL_DIRECT` pointing at `op-db` directly, without the `pgbouncer=true` flag. Migrations use the direct connection and will not work through a transaction-mode pooler.
|
||||
|
||||
```bash
|
||||
DATABASE_URL_DIRECT=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
PGBouncer runs in transaction mode. Prisma migrations and interactive transactions require a direct connection. Always set `DATABASE_URL_DIRECT` to the `op-db` address.
|
||||
</Callout>
|
||||
|
||||
### Tuning the pool size
|
||||
|
||||
A rough rule: `DEFAULT_POOL_SIZE` should not exceed your PostgreSQL `max_connections` divided by the number of distinct database/user pairs. The PostgreSQL default is 100. If you raise `max_connections` in Postgres, you can raise `DEFAULT_POOL_SIZE` proportionally.
|
||||
|
||||
---
|
||||
|
||||
## Buffer tuning
|
||||
|
||||
Events, sessions, and profiles flow through in-memory Redis buffers before being written to ClickHouse in batches. The defaults are conservative. Under high load you want larger batches to reduce the number of ClickHouse inserts and improve throughput.
|
||||
|
||||
### Event buffer
|
||||
|
||||
The event buffer collects incoming events in Redis and flushes them to ClickHouse on a cron schedule.
|
||||
|
||||
| Variable | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `EVENT_BUFFER_BATCH_SIZE` | `4000` | How many events are read from Redis and sent to ClickHouse per flush |
|
||||
| `EVENT_BUFFER_CHUNK_SIZE` | `1000` | How many events are sent in a single ClickHouse insert call |
|
||||
| `EVENT_BUFFER_MICRO_BATCH_MS` | `10` | How long (ms) to accumulate events in memory before writing to Redis |
|
||||
| `EVENT_BUFFER_MICRO_BATCH_SIZE` | `100` | Max events to accumulate before forcing a Redis write |
|
||||
|
||||
For high throughput, increase `EVENT_BUFFER_BATCH_SIZE` so each flush processes more events. Keep `EVENT_BUFFER_CHUNK_SIZE` at or below `EVENT_BUFFER_BATCH_SIZE`.
|
||||
|
||||
```bash
|
||||
EVENT_BUFFER_BATCH_SIZE=10000
|
||||
EVENT_BUFFER_CHUNK_SIZE=2000
|
||||
```
|
||||
|
||||
### Session buffer
|
||||
|
||||
Sessions are updated on each event and flushed to ClickHouse separately.
|
||||
|
||||
| Variable | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `SESSION_BUFFER_BATCH_SIZE` | `1000` | Events read per flush |
|
||||
| `SESSION_BUFFER_CHUNK_SIZE` | `1000` | Events per ClickHouse insert |
|
||||
|
||||
```bash
|
||||
SESSION_BUFFER_BATCH_SIZE=5000
|
||||
SESSION_BUFFER_CHUNK_SIZE=2000
|
||||
```
|
||||
|
||||
### Profile buffer
|
||||
|
||||
Profiles are merged with existing data before writing. The default batch size is small because each profile may require a ClickHouse lookup.
|
||||
|
||||
| Variable | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `PROFILE_BUFFER_BATCH_SIZE` | `200` | Profiles processed per flush |
|
||||
| `PROFILE_BUFFER_CHUNK_SIZE` | `1000` | Profiles per ClickHouse insert |
|
||||
| `PROFILE_BUFFER_TTL_IN_SECONDS` | `3600` | How long a profile stays cached in Redis |
|
||||
|
||||
Raise `PROFILE_BUFFER_BATCH_SIZE` if profile processing is a bottleneck. Higher values mean fewer flushes but more memory used per flush.
|
||||
|
||||
```bash
|
||||
PROFILE_BUFFER_BATCH_SIZE=500
|
||||
PROFILE_BUFFER_CHUNK_SIZE=1000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scaling ingestion
|
||||
|
||||
If the event queue is growing faster than workers can drain it, you have a few options.
|
||||
|
||||
Start vertical before going horizontal. Each worker replica adds overhead: more Redis connections, more ClickHouse connections, more memory. Increasing concurrency on an existing replica is almost always cheaper and more effective than adding another one.
|
||||
|
||||
### Increase job concurrency (do this first)
|
||||
|
||||
Each worker processes multiple jobs in parallel. The default is `10` per replica.
|
||||
|
||||
```bash
|
||||
EVENT_JOB_CONCURRENCY=20
|
||||
```
|
||||
|
||||
Raise this in steps and watch your queue depth. The limit is memory, not logic — values of `500`, `1000`, or even `2000+` are possible on hardware with enough RAM. Each concurrent job holds event data in memory, so monitor usage as you increase the value. Only add more replicas once concurrency alone stops helping.
|
||||
|
||||
### Add more worker replicas
|
||||
|
||||
If you have maxed out concurrency and the queue is still falling behind, add more replicas.
|
||||
|
||||
In `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
op-worker:
|
||||
deploy:
|
||||
replicas: 8
|
||||
```
|
||||
|
||||
Or at runtime:
|
||||
|
||||
```bash
|
||||
docker compose up -d --scale op-worker=8
|
||||
```
|
||||
|
||||
### Shard the events queue
|
||||
|
||||
<Callout type="warn">
|
||||
**Experimental.** Queue sharding requires either a Redis Cluster or Dragonfly. Dragonfly has seen minimal testing and Redis Cluster has not been tested at all. Do not use this in production without validating it in your environment first.
|
||||
</Callout>
|
||||
|
||||
Redis is single-threaded, so a single queue instance can become the bottleneck at very high event rates. Queue sharding works around this by splitting the queue across multiple independent shards. Each shard can be backed by its own Redis instance, so the throughput scales with the number of instances rather than being capped by one core.
|
||||
|
||||
Events are distributed across shards by project ID, so ordering within a project is preserved.
|
||||
|
||||
```bash
|
||||
EVENTS_GROUP_QUEUES_SHARDS=4
|
||||
QUEUE_CLUSTER=true
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
Set `EVENTS_GROUP_QUEUES_SHARDS` before you have live traffic on the queue. Changing it while jobs are pending will cause those jobs to be looked up on the wrong shard and they will not be processed until the shard count is restored.
|
||||
</Callout>
|
||||
|
||||
### Tune the ordering delay
|
||||
|
||||
Events arriving out of order are held briefly before processing. The default is `100ms`.
|
||||
|
||||
```bash
|
||||
ORDERING_DELAY_MS=100
|
||||
```
|
||||
|
||||
Lowering this reduces latency but increases the chance of out-of-order writes to ClickHouse. The value should not exceed `500ms`.
|
||||
|
||||
---
|
||||
|
||||
## Putting it together
|
||||
|
||||
A starting point for a high-volume `.env`:
|
||||
|
||||
```bash
|
||||
# Route app traffic through PGBouncer
|
||||
DATABASE_URL=postgresql://postgres:postgres@op-pgbouncer:5432/postgres?schema=public&pgbouncer=true
|
||||
# Keep direct connection for migrations
|
||||
DATABASE_URL_DIRECT=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
|
||||
|
||||
# Event buffer
|
||||
EVENT_BUFFER_BATCH_SIZE=10000
|
||||
EVENT_BUFFER_CHUNK_SIZE=2000
|
||||
|
||||
# Session buffer
|
||||
SESSION_BUFFER_BATCH_SIZE=5000
|
||||
SESSION_BUFFER_CHUNK_SIZE=2000
|
||||
|
||||
# Profile buffer
|
||||
PROFILE_BUFFER_BATCH_SIZE=500
|
||||
|
||||
# Queue
|
||||
EVENTS_GROUP_QUEUES_SHARDS=4
|
||||
EVENT_JOB_CONCURRENCY=20
|
||||
```
|
||||
|
||||
Then start with more workers:
|
||||
|
||||
```bash
|
||||
docker compose up -d --scale op-worker=8
|
||||
```
|
||||
|
||||
Monitor the Redis queue depth and ClickHouse insert latency as you tune. The right values depend on your hardware, event shape, and traffic pattern.
|
||||
@@ -8,7 +8,8 @@
|
||||
"[Deploy with Dokploy](/docs/self-hosting/deploy-dokploy)",
|
||||
"[Deploy on Kubernetes](/docs/self-hosting/deploy-kubernetes)",
|
||||
"[Environment Variables](/docs/self-hosting/environment-variables)",
|
||||
"[High volume setup](/docs/self-hosting/high-volume)",
|
||||
"supporter-access-latest-docker-images",
|
||||
"changelog"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,4 +164,4 @@
|
||||
"label": "Track your first conversion",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,4 +172,4 @@
|
||||
"label": "Start visualizing your data",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,4 +136,4 @@
|
||||
"label": "Track events in minutes",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user