Compare commits
238 Commits
feature/re
...
feature/gs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a96e7b038 | ||
|
|
fc256124b5 | ||
|
|
df0258f532 | ||
|
|
0f9e5f6e93 | ||
|
|
c9cf7901ad | ||
|
|
2981638893 | ||
|
|
70ca44f039 | ||
|
|
00f6cd6f50 | ||
|
|
227d629dc5 | ||
|
|
f2e19093f0 | ||
|
|
7f85b2ac0a | ||
|
|
38965387da | ||
|
|
74bcb7ead2 | ||
|
|
2377f95b86 | ||
|
|
de6ca96628 | ||
|
|
9e46099246 | ||
|
|
83761638f2 | ||
|
|
885f7225db | ||
|
|
553e4cf675 | ||
|
|
f2c414b4b4 | ||
|
|
043730444a | ||
|
|
8c377c2066 | ||
|
|
647ac2a4af | ||
|
|
6251d143d1 | ||
|
|
b801d6a8ef | ||
|
|
1272466235 | ||
|
|
2501ee1eef | ||
|
|
10da7d3a1d | ||
|
|
b0aa7f4196 | ||
|
|
f4602f8e56 | ||
|
|
efb50fafdb | ||
|
|
cd112237e9 | ||
|
|
9c6c7bb037 | ||
|
|
928c44ef6a | ||
|
|
a42adcdbfb | ||
|
|
8b18b86deb | ||
|
|
8db5905fb5 | ||
|
|
4b150dd987 | ||
|
|
baedf4343b | ||
|
|
22fb4acf12 | ||
|
|
cb3f8016df | ||
|
|
d9afeffbf1 | ||
|
|
4b9852b36f | ||
|
|
00e94ecb66 | ||
|
|
3d84e4e77b | ||
|
|
791668526c | ||
|
|
02ddcf9a3d | ||
|
|
55bd5c4614 | ||
|
|
aa81bbfe77 | ||
|
|
38d9b65ec8 | ||
|
|
f311146ade | ||
|
|
6dca57d7ce | ||
|
|
a4cb410d3e | ||
|
|
c69ff7053a | ||
|
|
ee27568824 | ||
|
|
7e2d93db45 | ||
|
|
03c18b37ec | ||
|
|
b81a2e0de6 | ||
|
|
d7e6e737c9 | ||
|
|
1281cfa7b3 | ||
|
|
81b85e09b8 | ||
|
|
5a0769c917 | ||
|
|
fef4941e06 | ||
|
|
cdc286b3fd | ||
|
|
fbb2606130 | ||
|
|
bef3eb2e90 | ||
|
|
ab59c12721 | ||
|
|
e3faab7588 | ||
|
|
0ebe2768be | ||
|
|
b7eafb0f37 | ||
|
|
edadda2c45 | ||
|
|
b6b716f1a1 | ||
|
|
d54a33b5c2 | ||
|
|
4f4b4a8d88 | ||
|
|
2f95c074c5 | ||
|
|
860223f22e | ||
|
|
6c55f7a759 | ||
|
|
5d802c40df | ||
|
|
3fc45acbd2 | ||
|
|
22fa473706 | ||
|
|
9d6087c32a | ||
|
|
1ccc862e9b | ||
|
|
b3afaf3d5f | ||
|
|
68b12b36cc | ||
|
|
2389b351e4 | ||
|
|
f1f932c58b | ||
|
|
c8d78e31a1 | ||
|
|
9f441fd9fa | ||
|
|
40a3178b57 | ||
|
|
e129a53ce5 | ||
|
|
6ce9b5dd1b | ||
|
|
ed8b5c667e | ||
|
|
fc3b6fb891 | ||
|
|
40b0774ef8 | ||
|
|
b0d531d793 | ||
|
|
063e80a659 | ||
|
|
c4d76d0a57 | ||
|
|
21792bfbfa | ||
|
|
19a48e86c1 | ||
|
|
c978904815 | ||
|
|
7a88b262c0 | ||
|
|
2d0478d626 | ||
|
|
ef8c2a4eee | ||
|
|
02897e11cb | ||
|
|
05ccbc372c | ||
|
|
f1c85c53cf | ||
|
|
18600aa5ab | ||
|
|
2c7edec274 | ||
|
|
b8469f8431 | ||
|
|
f6b2c6099f | ||
|
|
1cde7a3e35 | ||
|
|
64bcd5c155 | ||
|
|
d330053874 | ||
|
|
db62919825 | ||
|
|
286f8e160b | ||
|
|
f8f470adf9 | ||
|
|
e7c2834ea0 | ||
|
|
753d6dce4c | ||
|
|
9e5b482447 | ||
|
|
32ea28b2f6 | ||
|
|
b39d076b32 | ||
|
|
ec5937e55c | ||
|
|
f83fe7a0fc | ||
|
|
6c56efdf37 | ||
|
|
e5be28a49d | ||
|
|
e645c094b2 | ||
|
|
67301d928c | ||
|
|
deb3c3d20c | ||
|
|
6e997e62f1 | ||
|
|
2c5ca8adec | ||
|
|
3e573ae27f | ||
|
|
5b29f7502c | ||
|
|
d32a279949 | ||
|
|
ed6e5cd334 | ||
|
|
cf1bf95388 | ||
|
|
5830277ba9 | ||
|
|
aa13c87e87 | ||
|
|
83c3647f66 | ||
|
|
927613c09d | ||
|
|
24ee6b0b6c | ||
|
|
13d8b92cf3 | ||
|
|
4b2db351c4 | ||
|
|
334adec9f2 | ||
|
|
9a54daae55 | ||
|
|
7cd5f84c58 | ||
|
|
470ddbe8e7 | ||
|
|
c63578b35b | ||
|
|
b5792df69f | ||
|
|
00f2e2937d | ||
|
|
0d1773eb74 | ||
|
|
ed1c57dbb8 | ||
|
|
39251c8598 | ||
|
|
9a4aa51975 | ||
|
|
f008fb58e5 | ||
|
|
cabfb1f3f0 | ||
|
|
4867260ece | ||
|
|
87c919f700 | ||
|
|
3c085e445d | ||
|
|
6d9e3ce8e5 | ||
|
|
f187065d75 | ||
|
|
d5e4518e32 | ||
|
|
1f088d2208 | ||
|
|
3bd1f99d28 | ||
|
|
9a916f3171 | ||
|
|
34cb186ead | ||
|
|
5f38560373 | ||
|
|
1e4f02fb5e | ||
|
|
3158ebfbda | ||
|
|
d7c6e88adc | ||
|
|
3b61b28290 | ||
|
|
8dfeaa870c | ||
|
|
329f76b7ce | ||
|
|
3b74d8ae36 | ||
|
|
a2a53cf9f7 | ||
|
|
4e7dc16619 | ||
|
|
0f9ac4508a | ||
|
|
c46cda12eb | ||
|
|
546ef6673f | ||
|
|
3b2ed3afb1 | ||
|
|
95846f80e5 | ||
|
|
1f5c648afe | ||
|
|
3d8a3e8997 | ||
|
|
28692d82ae | ||
|
|
4bdbb31180 | ||
|
|
be248717d2 | ||
|
|
56bd1197a6 | ||
|
|
9ccca322e5 | ||
|
|
86a3da869b | ||
|
|
ae383001bc | ||
|
|
9bedd39e48 | ||
|
|
7131e3f461 | ||
|
|
9665a2593f | ||
|
|
c201bea682 | ||
|
|
8312556b38 | ||
|
|
d2b22867b9 | ||
|
|
2b8bcf1ed7 | ||
|
|
e22a5b3237 | ||
|
|
315e4a59a3 | ||
|
|
969c0bc8fe | ||
|
|
4e42689115 | ||
|
|
d3522c51f8 | ||
|
|
abf5353ab3 | ||
|
|
2dda50fc7c | ||
|
|
cbdb3a62c1 | ||
|
|
4b775ff2c5 | ||
|
|
a37e37c28b | ||
|
|
64afd04f7b | ||
|
|
2468dc29ff | ||
|
|
662975dc08 | ||
|
|
0a1564c798 | ||
|
|
56b01ca6d8 | ||
|
|
e5b9865850 | ||
|
|
5576519a2a | ||
|
|
1f74ab99ae | ||
|
|
3ae7d1322e | ||
|
|
e4b919c4da | ||
|
|
50ef4c0d94 | ||
|
|
a7a357eb0f | ||
|
|
7adc2903d2 | ||
|
|
ac4429d6d9 | ||
|
|
e2536774b0 | ||
|
|
a39796a829 | ||
|
|
8b31e4cfba | ||
|
|
ae09748e4e | ||
|
|
4ef0b1afe2 | ||
|
|
620904b4d4 | ||
|
|
d4e3470f7e | ||
|
|
d154e12d66 | ||
|
|
b421474616 | ||
|
|
828c8c4f91 | ||
|
|
6da8267509 | ||
|
|
86903b1937 | ||
|
|
4f9d66693e | ||
|
|
32415d31d9 | ||
|
|
f7055c0ebd | ||
|
|
c9cf0274af | ||
|
|
44184236a8 | ||
|
|
71270b3493 |
170
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# CLAUDE.md
|
||||
|
||||
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Openpanel is an open-source web/product analytics platform (Mixpanel alternative). It's a **pnpm monorepo** with apps, packages, tooling, and SDKs.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Run all services (api, worker, dashboard) in parallel
|
||||
pnpm dev:public # Run public/docs site only
|
||||
pnpm dock:up / dock:down # Start/stop Docker (PostgreSQL, Redis, ClickHouse)
|
||||
|
||||
# Code quality
|
||||
pnpm check # Lint check (Biome via Ultracite)
|
||||
pnpm fix # Auto-fix lint/format issues
|
||||
pnpm typecheck # Typecheck all packages
|
||||
|
||||
# Testing
|
||||
pnpm test # Run all tests (vitest)
|
||||
pnpm vitest run <path> # Run a single test file
|
||||
# Workspace: packages/* and apps/* (excluding apps/start)
|
||||
|
||||
# Database
|
||||
pnpm codegen # Generate Prisma types + geo data
|
||||
pnpm migrate # Run Prisma migrations (dev)
|
||||
pnpm migrate:deploy # Deploy migrations (production - never run this)
|
||||
|
||||
# Docker utilities
|
||||
pnpm dock:ch # ClickHouse CLI
|
||||
pnpm dock:redis # Redis CLI
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Apps
|
||||
|
||||
| App | Stack | Port | Purpose |
|
||||
|-----|-------|------|---------|
|
||||
| `apps/api` | Fastify + tRPC | 3333 | REST/RPC API server |
|
||||
| `apps/start` | TanStack Start (Vite + React 19) | 3000 | Dashboard SPA |
|
||||
| `apps/public` | Next.js 16 + Fumadocs | - | Marketing/docs site |
|
||||
| `apps/worker` | Express + BullMQ | 9999 | Background job processor |
|
||||
|
||||
### Key Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `packages/db` | Prisma ORM (PostgreSQL) + ClickHouse client |
|
||||
| `packages/trpc` | tRPC router definitions, context, middleware |
|
||||
| `packages/auth` | Authentication (Arctic OAuth, Oslo sessions, argon2) |
|
||||
| `packages/queue` | BullMQ + GroupMQ job queue definitions |
|
||||
| `packages/redis` | Redis client + LRU caching |
|
||||
| `packages/validation` | Zod schemas shared across apps |
|
||||
| `packages/common` | Shared utilities (date-fns, ua-parser, nanoid) |
|
||||
| `packages/email` | React Email templates via Resend |
|
||||
| `packages/sdks/*` | Client SDKs (web, react, next, express, react-native, etc.) |
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Event ingestion**: Client SDKs → `apps/api` (track routes) → Redis queue
|
||||
2. **Processing**: `apps/worker` picks up jobs from BullMQ, batches events into ClickHouse
|
||||
3. **Dashboard queries**: `apps/start` → tRPC → `apps/api` → ClickHouse (analytics) / PostgreSQL (config)
|
||||
4. **Real-time**: WebSocket via Fastify, pub/sub via Redis
|
||||
|
||||
### Three-Database Strategy
|
||||
|
||||
- **PostgreSQL**: Relational data (users, orgs, projects, dashboards). Managed by Prisma.
|
||||
- **ClickHouse**: Analytics event storage (OLAP). High-volume reads/writes.
|
||||
- **Redis**: Caching, job queues (BullMQ), rate limiting, pub/sub.
|
||||
|
||||
### Dashboard (apps/start)
|
||||
|
||||
Uses TanStack Router with file-based routing (`src/routes/`). State management via Redux Toolkit. UI built on Radix primitives + Tailwind v4. Charts via Recharts. Modals in `src/modals/`.
|
||||
|
||||
### API (apps/api)
|
||||
|
||||
Fastify server with tRPC integration. Route files in `src/routes/`. Hooks for IP extraction, request logging, timestamps. Built with `tsdown`.
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
|
||||
|
||||
### Type Safety & Explicitness
|
||||
|
||||
- Use explicit types for function parameters and return values when they enhance clarity
|
||||
- Prefer `unknown` over `any` when the type is genuinely unknown
|
||||
- Use const assertions (`as const`) for immutable values and literal types
|
||||
- Leverage TypeScript's type narrowing instead of type assertions
|
||||
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
|
||||
|
||||
### Modern JavaScript/TypeScript
|
||||
|
||||
- Use arrow functions for callbacks and short functions
|
||||
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
|
||||
- Prefer template literals over string concatenation
|
||||
- Use destructuring for object and array assignments
|
||||
- Use `const` by default, `let` only when reassignment is needed, never `var`
|
||||
|
||||
### Async & Promises
|
||||
|
||||
- Always `await` promises in async functions - don't forget to use the return value
|
||||
- Use `async/await` syntax instead of promise chains for better readability
|
||||
- Handle errors appropriately in async code with try-catch blocks
|
||||
- Don't use async functions as Promise executors
|
||||
|
||||
### React & JSX
|
||||
|
||||
- Use function components over class components
|
||||
- Call hooks at the top level only, never conditionally
|
||||
- Specify all dependencies in hook dependency arrays correctly
|
||||
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
|
||||
- Nest children between opening and closing tags instead of passing as props
|
||||
- Don't define components inside other components
|
||||
- Use semantic HTML and ARIA attributes for accessibility:
|
||||
- Provide meaningful alt text for images
|
||||
- Use proper heading hierarchy
|
||||
- Add labels for form inputs
|
||||
- Include keyboard event handlers alongside mouse events
|
||||
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
|
||||
|
||||
### Error Handling & Debugging
|
||||
|
||||
- Remove `console.log`, `debugger`, and `alert` statements from production code
|
||||
- Throw `Error` objects with descriptive messages, not strings or other values
|
||||
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
|
||||
- Prefer early returns over nested conditionals for error cases
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep functions focused and under reasonable cognitive complexity limits
|
||||
- Extract complex conditions into well-named boolean variables
|
||||
- Use early returns to reduce nesting
|
||||
- Prefer simple conditionals over nested ternary operators
|
||||
- Group related code together and separate concerns
|
||||
|
||||
### Security
|
||||
|
||||
- Add `rel="noopener"` when using `target="_blank"` on links
|
||||
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
|
||||
- Don't use `eval()` or assign directly to `document.cookie`
|
||||
- Validate and sanitize user input
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid spread syntax in accumulators within loops
|
||||
- Use top-level regex literals instead of creating them in loops
|
||||
- Prefer specific imports over namespace imports
|
||||
- Avoid barrel files (index files that re-export everything)
|
||||
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
|
||||
|
||||
### Framework-Specific Guidance
|
||||
|
||||
**Next.js:**
|
||||
- Use Next.js `<Image>` component for images
|
||||
- Use `next/head` or App Router metadata API for head elements
|
||||
- Use Server Components for async data fetching instead of async Client Components
|
||||
|
||||
**React 19+:**
|
||||
- Use ref as a prop instead of `React.forwardRef`
|
||||
|
||||
**Solid/Svelte/Vue/Qwik:**
|
||||
- Use `class` and `for` attributes (not `className` or `htmlFor`)
|
||||
74
.github/workflows/docker-build.yml
vendored
@@ -3,54 +3,37 @@ name: Docker Build and Push
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# branches: [ "main" ]
|
||||
paths:
|
||||
- "apps/api/**"
|
||||
- "apps/worker/**"
|
||||
paths-ignore:
|
||||
# README and docs
|
||||
- "**/README*"
|
||||
- "**/readme*"
|
||||
- "**/*.md"
|
||||
- "**/docs/**"
|
||||
- "**/CHANGELOG*"
|
||||
- "**/LICENSE*"
|
||||
# Test files
|
||||
- "**/*.test.*"
|
||||
- "**/*.spec.*"
|
||||
- "**/__tests__/**"
|
||||
- "**/tests/**"
|
||||
# SDKs (published separately)
|
||||
- "packages/sdks/**"
|
||||
# Public app (docs/marketing, not part of Docker deploy)
|
||||
- "apps/public/**"
|
||||
- "packages/**"
|
||||
- "!packages/sdks/**"
|
||||
- "**Dockerfile"
|
||||
- ".github/workflows/**"
|
||||
# Dev / tooling
|
||||
- "**/.vscode/**"
|
||||
- "**/.cursor/**"
|
||||
- "**/.env.example"
|
||||
- "**/.env.*.example"
|
||||
- "**/.gitignore"
|
||||
- "**/.eslintignore"
|
||||
- "**/.prettierignore"
|
||||
|
||||
env:
|
||||
repo_owner: "openpanel-dev"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
api: ${{ steps.filter.outputs.api }}
|
||||
worker: ${{ steps.filter.outputs.worker }}
|
||||
public: ${{ steps.filter.outputs.public }}
|
||||
dashboard: ${{ steps.filter.outputs.dashboard }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
base: "main"
|
||||
filters: |
|
||||
api:
|
||||
- 'apps/api/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
worker:
|
||||
- 'apps/worker/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
public:
|
||||
- 'apps/public/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
dashboard:
|
||||
- 'apps/start/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
|
||||
lint-and-test:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' || needs.changes.outputs.dashboard == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
@@ -105,8 +88,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
needs: [changes, lint-and-test]
|
||||
if: ${{ needs.changes.outputs.api == 'true' }}
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -166,8 +148,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
needs: [changes, lint-and-test]
|
||||
if: ${{ needs.changes.outputs.worker == 'true' }}
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -227,8 +208,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
needs: [changes, lint-and-test]
|
||||
if: ${{ needs.changes.outputs.dashboard == 'true' }}
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
**/.open-next
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
50
.zed/settings.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"formatter": "language_server",
|
||||
"format_on_save": "on",
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"settings": {
|
||||
"typescript": {
|
||||
"preferences": {
|
||||
"includePackageJsonAutoImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"JavaScript": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
}
|
||||
},
|
||||
"TypeScript": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
}
|
||||
},
|
||||
"TSX": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<h1 align="center"><b>Openpanel</b></h1>
|
||||
@@ -28,6 +28,7 @@ Openpanel is an open-source web and product analytics platform that combines the
|
||||
## ✨ Features
|
||||
|
||||
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
|
||||
- **🎬 Session Replay**: Record and replay user sessions with privacy controls built in
|
||||
- **📊 Real-time Dashboards**: Live data updates and interactive charts
|
||||
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
|
||||
- **🔔 Smart Notifications**: Event and funnel-based alerts
|
||||
@@ -48,6 +49,7 @@ Openpanel is an open-source web and product analytics platform that combines the
|
||||
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
|
||||
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
|
||||
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
|
||||
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
|
||||
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
|
||||
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
|
||||
@@ -56,9 +58,10 @@ Openpanel is an open-source web and product analytics platform that combines the
|
||||
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
|
||||
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
|
||||
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
|
||||
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
|
||||
> ✅*** Plausible has simple goals
|
||||
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -98,6 +101,10 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
|
||||
### Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
echo "API_URL=http://localhost:3333" > apps/start/.env
|
||||
|
||||
pnpm dock:up
|
||||
pnpm codegen
|
||||
pnpm migrate:deploy # once to setup the db
|
||||
@@ -110,4 +117,4 @@ You can now access the following:
|
||||
- API: https://api.localhost:3333
|
||||
- Bullboard (queue): http://localhost:9999
|
||||
- `pnpm dock:ch` to access clickhouse terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"rootDir": "src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -38,11 +38,10 @@ COPY packages/redis/package.json packages/redis/
|
||||
COPY packages/logger/package.json packages/logger/
|
||||
COPY packages/common/package.json packages/common/
|
||||
COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
COPY packages/integrations/package.json packages/integrations/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/js-runtime/package.json packages/js-runtime/
|
||||
COPY patches ./patches
|
||||
|
||||
# BUILD
|
||||
@@ -107,10 +106,10 @@ COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/common ./packages/common
|
||||
COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||
RUN pnpm db:codegen
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "dotenv -e ../../.env node dist/index.js",
|
||||
"build": "rm -rf dist && tsdown",
|
||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
||||
"test:manage": "jiti scripts/test-manage-api.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29,6 +30,7 @@
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"groupmq": "catalog:",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
@@ -38,7 +40,6 @@
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "1.1.0-next.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"sharp": "^0.33.5",
|
||||
@@ -46,13 +47,12 @@
|
||||
"sqlstring": "^2.3.3",
|
||||
"superjson": "^1.13.3",
|
||||
"svix": "^1.24.0",
|
||||
"url-metadata": "^4.1.1",
|
||||
"url-metadata": "^5.4.1",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@openpanel/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
@@ -65,4 +65,4 @@
|
||||
"tsdown": "0.14.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
340
apps/api/scripts/test-manage-api.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* One-off script to test all /manage/ API endpoints
|
||||
*
|
||||
* Usage:
|
||||
* pnpm test:manage
|
||||
* or
|
||||
* pnpm jiti scripts/test-manage-api.ts
|
||||
*
|
||||
* Set API_URL environment variable to test against a different server:
|
||||
* API_URL=http://localhost:3000 pnpm test:manage
|
||||
*/
|
||||
|
||||
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) {
|
||||
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
): Promise<TestResult> {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'openpanel-client-id': CLIENT_ID,
|
||||
'openpanel-client-secret': CLIENT_SECRET,
|
||||
};
|
||||
|
||||
// Only set Content-Type if there's a body
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : data.message || 'Request failed',
|
||||
data: response.ok ? data : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: 0,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testProjects() {
|
||||
console.log('\n📁 Testing Projects endpoints...\n');
|
||||
|
||||
// Create project
|
||||
const createResult = await makeRequest('POST', '/manage/projects', {
|
||||
name: `Test Project ${Date.now()}`,
|
||||
domain: 'https://example.com',
|
||||
cors: ['https://example.com', 'https://www.example.com'],
|
||||
crossDomain: false,
|
||||
types: ['website'],
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const projectId = createResult.data?.data?.id;
|
||||
const clientId = createResult.data?.data?.client?.id;
|
||||
const clientSecret = createResult.data?.data?.client?.secret;
|
||||
|
||||
if (projectId) {
|
||||
console.log(` Created project: ${projectId}`);
|
||||
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}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} projects`);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
// Get project
|
||||
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update project
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/projects/${projectId}`,
|
||||
{
|
||||
name: 'Updated Test Project',
|
||||
crossDomain: true,
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete project (soft delete)
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/projects/${projectId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { projectId, clientId };
|
||||
}
|
||||
|
||||
async function testClients(projectId?: string) {
|
||||
console.log('\n🔑 Testing Clients endpoints...\n');
|
||||
|
||||
// Create client
|
||||
const createResult = await makeRequest('POST', '/manage/clients', {
|
||||
name: `Test Client ${Date.now()}`,
|
||||
projectId: projectId || undefined,
|
||||
type: 'read',
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
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}`);
|
||||
}
|
||||
|
||||
// List clients
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} clients`);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
// Get client
|
||||
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update client
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/clients/${clientId}`,
|
||||
{
|
||||
name: 'Updated Test Client',
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete client
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/clients/${clientId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testReferences(projectId?: string) {
|
||||
console.log('\n📚 Testing References endpoints...\n');
|
||||
|
||||
if (!projectId) {
|
||||
console.log(' ⚠️ Skipping references tests - no project ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create reference
|
||||
const createResult = await makeRequest('POST', '/manage/references', {
|
||||
projectId,
|
||||
title: `Test Reference ${Date.now()}`,
|
||||
description: 'This is a test reference',
|
||||
datetime: new Date().toISOString(),
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const referenceId = createResult.data?.data?.id;
|
||||
|
||||
if (referenceId) {
|
||||
console.log(` Created reference: ${referenceId}`);
|
||||
}
|
||||
|
||||
// List references
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references?projectId=${projectId}`,
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} references`);
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
// Get reference
|
||||
const getResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update reference
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/references/${referenceId}`,
|
||||
{
|
||||
title: 'Updated Test Reference',
|
||||
description: 'Updated description',
|
||||
datetime: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete reference
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Testing Manage API Endpoints\n');
|
||||
console.log(`API Base URL: ${API_BASE_URL}`);
|
||||
console.log(`Client ID: ${CLIENT_ID}\n`);
|
||||
|
||||
try {
|
||||
// Test projects first (creates a project we can use for other tests)
|
||||
const { projectId } = await testProjects();
|
||||
|
||||
// Test clients
|
||||
await testClients(projectId);
|
||||
|
||||
// Test references (requires a project)
|
||||
await testReferences(projectId);
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('📊 Test Summary\n');
|
||||
const successful = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
console.log(`Total tests: ${results.length}`);
|
||||
console.log(`✅ Successful: ${successful}`);
|
||||
console.log(`❌ Failed: ${failed}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('Failed tests:');
|
||||
results
|
||||
.filter((r) => !r.success)
|
||||
.forEach((r) => {
|
||||
console.log(` ❌ ${r.name} (${r.status})`);
|
||||
if (r.error) console.log(` Error: ${r.error}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,26 +1,25 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import type { PostEventPayload } from '@openpanel/sdk';
|
||||
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||
import { getDeviceId } from '@/utils/ids';
|
||||
|
||||
export async function postEvent(
|
||||
request: FastifyRequest<{
|
||||
Body: PostEventPayload;
|
||||
Body: DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { timestamp, isTimestampFromThePast } = getTimestamp(
|
||||
request.timestamp,
|
||||
request.body,
|
||||
request.body
|
||||
);
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -30,34 +29,22 @@ export async function postEvent(
|
||||
}
|
||||
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const { deviceId, sessionId } = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
});
|
||||
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? request.body?.profileId
|
||||
? `${projectId}:${request.body?.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
? `${projectId}:${request.body?.profileId ?? generateId()}`
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
request.body.name,
|
||||
slug(request.body.name),
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
deviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -74,8 +61,8 @@ export async function postEvent(
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
deviceId,
|
||||
sessionId: sessionId ?? '',
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { DateTime } from '@openpanel/common';
|
||||
import type { GetEventListOptions } from '@openpanel/db';
|
||||
import {
|
||||
ChartEngine,
|
||||
ClientType,
|
||||
db,
|
||||
getEventList,
|
||||
getEventsCountCached,
|
||||
getEventsCount,
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
|
||||
async function getProjectId(
|
||||
request: FastifyRequest<{
|
||||
@@ -22,8 +20,7 @@ async function getProjectId(
|
||||
project_id?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
}>
|
||||
) {
|
||||
let projectId = request.query.projectId || request.query.project_id;
|
||||
|
||||
@@ -75,8 +72,20 @@ const eventsScheme = z.object({
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
includes: z
|
||||
.preprocess(
|
||||
(arg) => (typeof arg === 'string' ? [arg] : arg),
|
||||
z.array(z.string()),
|
||||
(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;
|
||||
}
|
||||
return arg;
|
||||
},
|
||||
z.array(z.string())
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
@@ -85,7 +94,7 @@ export async function events(
|
||||
request: FastifyRequest<{
|
||||
Querystring: z.infer<typeof eventsScheme>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const query = eventsScheme.safeParse(request.query);
|
||||
|
||||
@@ -97,7 +106,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const projectId = await getProjectId(request);
|
||||
const limit = query.data.limit;
|
||||
const page = Math.max(query.data.page, 1);
|
||||
const take = Math.max(Math.min(limit, 1000), 1);
|
||||
@@ -118,20 +127,20 @@ export async function events(
|
||||
meta: false,
|
||||
...query.data.includes?.reduce(
|
||||
(acc, key) => ({ ...acc, [key]: true }),
|
||||
{},
|
||||
{}
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
getEventList(options),
|
||||
getEventsCountCached(omit(['cursor', 'take'], options)),
|
||||
getEventsCount(options),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
meta: {
|
||||
count: data.length,
|
||||
totalCount: totalCount,
|
||||
totalCount,
|
||||
pages: Math.ceil(totalCount / options.take),
|
||||
current: cursor + 1,
|
||||
},
|
||||
@@ -139,7 +148,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zChartInputBase
|
||||
const chartSchemeFull = zReport
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
interval: true,
|
||||
@@ -158,7 +167,7 @@ const chartSchemeFull = zChartInputBase
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// Backward compatibility - events will be migrated to series via preprocessing
|
||||
@@ -169,7 +178,7 @@ const chartSchemeFull = zChartInputBase
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
@@ -178,7 +187,7 @@ export async function charts(
|
||||
request: FastifyRequest<{
|
||||
Querystring: Record<string, string>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
|
||||
|
||||
@@ -190,7 +199,7 @@ export async function charts(
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const projectId = await getProjectId(request);
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { events, series, ...rest } = query.data;
|
||||
|
||||
|
||||
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { googleGsc } from '@openpanel/auth';
|
||||
import { db, encrypt } from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { LogError } from '@/utils/errors';
|
||||
|
||||
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
|
||||
|
||||
function sanitizeOAuthQuery(
|
||||
query: Record<string, unknown> | null | undefined
|
||||
): Record<string, string> {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).map(([k, v]) => [
|
||||
k,
|
||||
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function gscGoogleCallback(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const schema = z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
const query = schema.safeParse(req.query);
|
||||
if (!query.success) {
|
||||
throw new LogError(
|
||||
'Invalid GSC callback query params',
|
||||
sanitizeOAuthQuery(req.query as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
const { code, state } = query.data;
|
||||
|
||||
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
|
||||
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
|
||||
const rawProjectId = req.cookies.gsc_project_id ?? null;
|
||||
|
||||
const storedStateResult =
|
||||
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
|
||||
const codeVerifierResult =
|
||||
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
|
||||
const projectIdResult =
|
||||
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
|
||||
|
||||
if (
|
||||
!(
|
||||
storedStateResult?.value &&
|
||||
codeVerifierResult?.value &&
|
||||
projectIdResult?.value
|
||||
)
|
||||
) {
|
||||
throw new LogError('Missing GSC OAuth cookies', {
|
||||
storedState: !storedStateResult?.value,
|
||||
codeVerifier: !codeVerifierResult?.value,
|
||||
projectId: !projectIdResult?.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
storedStateResult?.valid &&
|
||||
codeVerifierResult?.valid &&
|
||||
projectIdResult?.valid
|
||||
)
|
||||
) {
|
||||
throw new LogError('Invalid GSC OAuth cookies', {
|
||||
storedState: !storedStateResult?.value,
|
||||
codeVerifier: !codeVerifierResult?.value,
|
||||
projectId: !projectIdResult?.value,
|
||||
});
|
||||
}
|
||||
|
||||
const stateStr = storedStateResult?.value;
|
||||
const codeVerifierStr = codeVerifierResult?.value;
|
||||
const projectIdStr = projectIdResult?.value;
|
||||
|
||||
if (state !== stateStr) {
|
||||
throw new LogError('GSC OAuth state mismatch', {
|
||||
hasState: true,
|
||||
hasStoredState: true,
|
||||
stateMismatch: true,
|
||||
});
|
||||
}
|
||||
|
||||
const tokens = await googleGsc.validateAuthorizationCode(
|
||||
code,
|
||||
codeVerifierStr
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const refreshToken = tokens.hasRefreshToken()
|
||||
? tokens.refreshToken()
|
||||
: null;
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new LogError('No refresh token returned from Google GSC OAuth');
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectIdStr },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new LogError('Project not found for GSC connection', {
|
||||
projectId: projectIdStr,
|
||||
});
|
||||
}
|
||||
|
||||
await db.gscConnection.upsert({
|
||||
where: { projectId: projectIdStr },
|
||||
create: {
|
||||
projectId: projectIdStr,
|
||||
accessToken: encrypt(accessToken),
|
||||
refreshToken: encrypt(refreshToken),
|
||||
accessTokenExpiresAt,
|
||||
siteUrl: '',
|
||||
},
|
||||
update: {
|
||||
accessToken: encrypt(accessToken),
|
||||
refreshToken: encrypt(refreshToken),
|
||||
accessTokenExpiresAt,
|
||||
lastSyncStatus: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.clearCookie('gsc_oauth_state');
|
||||
reply.clearCookie('gsc_code_verifier');
|
||||
reply.clearCookie('gsc_project_id');
|
||||
|
||||
const dashboardUrl =
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
|
||||
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
|
||||
return reply.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
req.log.error(error);
|
||||
reply.clearCookie('gsc_oauth_state');
|
||||
reply.clearCookie('gsc_code_verifier');
|
||||
reply.clearCookie('gsc_project_id');
|
||||
return redirectWithError(reply, error);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||
const url = new URL(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
url.pathname = '/login';
|
||||
if (error instanceof LogError) {
|
||||
url.searchParams.set('error', error.message);
|
||||
} else {
|
||||
url.searchParams.set('error', 'Failed to connect Google Search Console');
|
||||
}
|
||||
url.searchParams.set('correlationId', reply.request.id);
|
||||
return reply.redirect(url.toString());
|
||||
}
|
||||
@@ -96,8 +96,6 @@ export async function getPages(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,8 +168,6 @@ export function getOverviewGeneric(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
649
apps/api/src/controllers/manage.controller.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
db,
|
||||
getClientByIdCached,
|
||||
getId,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
name: z.string().min(1),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().optional().default(false),
|
||||
types: z
|
||||
.array(z.enum(['website', 'app', 'backend']))
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const zUpdateProject = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).optional(),
|
||||
crossDomain: z.boolean().optional(),
|
||||
allowUnsafeRevenueTracking: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const zCreateClient = z.object({
|
||||
name: z.string().min(1),
|
||||
projectId: z.string().optional(),
|
||||
type: z.enum(['read', 'write', 'root']).optional().default('write'),
|
||||
});
|
||||
|
||||
const zUpdateClient = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zCreateReference = z.object({
|
||||
projectId: z.string(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string(),
|
||||
});
|
||||
|
||||
const zUpdateReference = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string().optional(),
|
||||
});
|
||||
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
deleteAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: projects });
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, domain, cors, crossDomain, types } = parsed.data;
|
||||
|
||||
// Generate a default client secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
const clientData = {
|
||||
organizationId: request.client!.organizationId,
|
||||
name: 'First client',
|
||||
type: 'write' as const,
|
||||
secret: await hashPassword(secret),
|
||||
};
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
id: await getId('project', name),
|
||||
organizationId: request.client!.organizationId,
|
||||
name,
|
||||
domain: domain ? stripTrailingSlash(domain) : null,
|
||||
cors: cors.map((c) => stripTrailingSlash(c)),
|
||||
crossDomain: crossDomain ?? false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
types,
|
||||
clients: {
|
||||
create: clientData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...project,
|
||||
client: project.clients[0]
|
||||
? {
|
||||
id: project.clients[0].id,
|
||||
secret,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify project exists and belongs to organization
|
||||
const existing = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
if (parsed.data.domain !== undefined) {
|
||||
updateData.domain = parsed.data.domain
|
||||
? stripTrailingSlash(parsed.data.domain)
|
||||
: null;
|
||||
}
|
||||
if (parsed.data.cors !== undefined) {
|
||||
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
|
||||
}
|
||||
if (parsed.data.crossDomain !== undefined) {
|
||||
updateData.crossDomain = parsed.data.crossDomain;
|
||||
}
|
||||
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
|
||||
updateData.allowUnsafeRevenueTracking =
|
||||
parsed.data.allowUnsafeRevenueTracking;
|
||||
}
|
||||
|
||||
const project = await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: {
|
||||
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await getProjectByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: clients });
|
||||
}
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, projectId, type } = parsed.data;
|
||||
|
||||
// If projectId is provided, verify it belongs to organization
|
||||
if (projectId) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
organizationId: request.client!.organizationId,
|
||||
projectId: projectId || null,
|
||||
name,
|
||||
type: type || 'write',
|
||||
secret: await hashPassword(secret),
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...client,
|
||||
secret, // Return plain secret only once
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to organization
|
||||
const existing = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
|
||||
const client = await db.client.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.client.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
} else {
|
||||
// If no projectId, get all projects in org and filter references
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
where.projectId = {
|
||||
in: projects.map((p) => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
const references = await db.reference.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: references });
|
||||
}
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { projectId, title, description, datetime } = parsed.data;
|
||||
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const reference = await db.reference.create({
|
||||
data: {
|
||||
projectId,
|
||||
title,
|
||||
description: description || null,
|
||||
date: new Date(datetime),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function updateReference(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reference exists and belongs to organization
|
||||
const existing = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.title !== undefined) {
|
||||
updateData.title = parsed.data.title;
|
||||
}
|
||||
if (parsed.data.description !== undefined) {
|
||||
updateData.description = parsed.data.description ?? null;
|
||||
}
|
||||
if (parsed.data.datetime !== undefined) {
|
||||
updateData.date = new Date(parsed.data.datetime);
|
||||
}
|
||||
|
||||
const reference = await db.reference.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.reference.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
@@ -71,7 +71,7 @@ async function fetchImage(
|
||||
url: URL,
|
||||
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
@@ -118,7 +118,11 @@ async function fetchImage(
|
||||
|
||||
// Check if URL is an ICO file
|
||||
function isIcoFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
|
||||
return (
|
||||
url.toLowerCase().endsWith('.ico') ||
|
||||
contentType === 'image/x-icon' ||
|
||||
contentType === 'image/vnd.microsoft.icon'
|
||||
);
|
||||
}
|
||||
function isSvgFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
||||
@@ -171,20 +175,10 @@ async function processImage(
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
// If Sharp fails, try to create a simple fallback image
|
||||
return createFallbackImage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a simple transparent fallback image when Sharp can't process the original
|
||||
function createFallbackImage(): Buffer {
|
||||
// 1x1 transparent PNG
|
||||
return Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
// Process OG image with Sharp (resize to 300px width)
|
||||
async function processOgImage(
|
||||
buffer: Buffer,
|
||||
@@ -216,8 +210,7 @@ async function processOgImage(
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
// If Sharp fails, try to create a simple fallback image
|
||||
return createFallbackImage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +230,15 @@ export async function getFavicon(
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
try {
|
||||
logger.info('getFavicon', {
|
||||
url: request.query.url,
|
||||
});
|
||||
const url = validateUrl(request.query.url);
|
||||
if (!url) {
|
||||
return createFallbackImage();
|
||||
return reply
|
||||
.status(404)
|
||||
.header('Content-Type', 'text/plain')
|
||||
.send('Not found');
|
||||
}
|
||||
|
||||
const cacheKey = createCacheKey(url.toString());
|
||||
@@ -253,29 +252,76 @@ export async function getFavicon(
|
||||
}
|
||||
|
||||
let imageUrl: URL;
|
||||
|
||||
// If it's a direct image URL, use it directly
|
||||
if (isDirectImage(url)) {
|
||||
imageUrl = url;
|
||||
} else {
|
||||
logger.info('before parseUrlMeta', {
|
||||
url: url.toString(),
|
||||
});
|
||||
// For website URLs, extract favicon from HTML
|
||||
const meta = await parseUrlMeta(url.toString());
|
||||
logger.info('parseUrlMeta result', {
|
||||
url: url.toString(),
|
||||
favicon: meta?.favicon,
|
||||
});
|
||||
if (meta?.favicon) {
|
||||
imageUrl = new URL(meta.favicon);
|
||||
} else {
|
||||
// Fallback to Google's favicon service
|
||||
const { hostname } = url;
|
||||
imageUrl = new URL(
|
||||
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
|
||||
);
|
||||
// Try standard favicon location first
|
||||
const { origin } = url;
|
||||
imageUrl = new URL(`${origin}/favicon.ico`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the image
|
||||
const { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
logger.info('Fetching favicon', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
});
|
||||
|
||||
if (status !== 200 || buffer.length === 0) {
|
||||
return reply.send(createFallbackImage());
|
||||
// Fetch the image
|
||||
let { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
|
||||
logger.info('Favicon fetch result', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
|
||||
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
|
||||
// try DuckDuckGo's favicon service as a fallback
|
||||
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
|
||||
const { hostname } = url;
|
||||
const duckduckgoUrl = new URL(
|
||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
|
||||
);
|
||||
|
||||
logger.info('Trying DuckDuckGo favicon service', {
|
||||
originalUrl: url.toString(),
|
||||
duckduckgoUrl: duckduckgoUrl.toString(),
|
||||
});
|
||||
|
||||
const duckduckgoResult = await fetchImage(duckduckgoUrl);
|
||||
buffer = duckduckgoResult.buffer;
|
||||
contentType = duckduckgoResult.contentType;
|
||||
status = duckduckgoResult.status;
|
||||
imageUrl = duckduckgoUrl;
|
||||
|
||||
logger.info('DuckDuckGo favicon result', {
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
// Accept any response as long as we have valid image data
|
||||
if (buffer.length === 0) {
|
||||
return reply
|
||||
.status(404)
|
||||
.header('Content-Type', 'text/plain')
|
||||
.send('Not found');
|
||||
}
|
||||
|
||||
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
|
||||
@@ -285,9 +331,31 @@ export async function getFavicon(
|
||||
contentType,
|
||||
);
|
||||
|
||||
logger.info('Favicon processing result', {
|
||||
originalUrl: url.toString(),
|
||||
originalBufferLength: buffer.length,
|
||||
processedBufferLength: processedBuffer.length,
|
||||
});
|
||||
|
||||
// Determine the correct content type for caching and response
|
||||
const isIco = isIcoFile(imageUrl.toString(), contentType);
|
||||
const responseContentType = isIco ? 'image/x-icon' : contentType;
|
||||
const isSvg = isSvgFile(imageUrl.toString(), contentType);
|
||||
let responseContentType = contentType;
|
||||
|
||||
if (isIco) {
|
||||
responseContentType = 'image/x-icon';
|
||||
} else if (isSvg) {
|
||||
responseContentType = 'image/svg+xml';
|
||||
} else if (
|
||||
processedBuffer.length < 5000 &&
|
||||
buffer.length === processedBuffer.length
|
||||
) {
|
||||
// Image was returned as-is, keep original content type
|
||||
responseContentType = contentType;
|
||||
} else {
|
||||
// Image was processed by Sharp, it's now a PNG
|
||||
responseContentType = 'image/png';
|
||||
}
|
||||
|
||||
// Cache the result with correct content type
|
||||
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { LogError } from '@/utils/errors';
|
||||
import {
|
||||
Arctic,
|
||||
type OAuth2Tokens,
|
||||
createSession,
|
||||
generateSessionToken,
|
||||
github,
|
||||
google,
|
||||
type OAuth2Tokens,
|
||||
setLastAuthProviderCookie,
|
||||
setSessionTokenCookie,
|
||||
} from '@openpanel/auth';
|
||||
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { LogError } from '@/utils/errors';
|
||||
|
||||
async function getGithubEmail(githubAccessToken: string) {
|
||||
const emailListRequest = new Request('https://api.github.com/user/emails');
|
||||
@@ -74,10 +75,14 @@ async function handleExistingUser({
|
||||
setSessionTokenCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
sessionToken,
|
||||
session.expiresAt,
|
||||
session.expiresAt
|
||||
);
|
||||
setLastAuthProviderCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
providerName
|
||||
);
|
||||
return reply.redirect(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +108,7 @@ async function handleNewUser({
|
||||
existingUser,
|
||||
oauthUser,
|
||||
providerName,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,10 +143,14 @@ async function handleNewUser({
|
||||
setSessionTokenCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
sessionToken,
|
||||
session.expiresAt,
|
||||
session.expiresAt
|
||||
);
|
||||
setLastAuthProviderCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
providerName
|
||||
);
|
||||
return reply.redirect(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +228,7 @@ interface ValidatedOAuthQuery {
|
||||
|
||||
async function validateOAuthCallback(
|
||||
req: FastifyRequest,
|
||||
provider: Provider,
|
||||
provider: Provider
|
||||
): Promise<ValidatedOAuthQuery> {
|
||||
const schema = z.object({
|
||||
code: z.string(),
|
||||
@@ -353,7 +362,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||
const url = new URL(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
url.pathname = '/login';
|
||||
if (error instanceof LogError) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type {
|
||||
IncrementProfilePayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@openpanel/sdk';
|
||||
DeprecatedIncrementProfilePayload,
|
||||
DeprecatedUpdateProfilePayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export async function updateProfile(
|
||||
request: FastifyRequest<{
|
||||
Body: UpdateProfilePayload;
|
||||
Body: DeprecatedUpdateProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -52,7 +52,7 @@ export async function updateProfile(
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
|
||||
|
||||
export async function decrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||
import {
|
||||
getProfileById,
|
||||
getSalts,
|
||||
replayBuffer,
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
TrackPayload,
|
||||
} from '@openpanel/sdk';
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type IReplayPayload,
|
||||
type ITrackHandlerPayload,
|
||||
type ITrackPayload,
|
||||
zTrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { getDeviceId } from '@/utils/ids';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
return Object.entries(
|
||||
@@ -25,36 +33,40 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
'openpanel-client-id',
|
||||
'request-id',
|
||||
],
|
||||
headers,
|
||||
),
|
||||
headers
|
||||
)
|
||||
).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: value ? String(value) : undefined,
|
||||
}),
|
||||
{},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity =
|
||||
'properties' in body.payload
|
||||
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
|
||||
: undefined;
|
||||
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
||||
if (body.type === 'track') {
|
||||
const identity = body.payload.properties?.__identify as
|
||||
| IIdentifyPayload
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
(body?.payload?.profileId
|
||||
if (identity) {
|
||||
return identity;
|
||||
}
|
||||
|
||||
return body.payload.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
profileId: String(body.payload.profileId),
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
payload: ITrackHandlerPayload['payload']
|
||||
) {
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
@@ -81,7 +93,7 @@ export function getTimestamp(
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 1 hour
|
||||
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
@@ -91,203 +103,134 @@ export function getTimestamp(
|
||||
};
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
'properties' in request.body.payload &&
|
||||
request.body.payload.properties?.__ip
|
||||
? (request.body.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
if (!projectId) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const profileId = identity?.profileId;
|
||||
const overrideDeviceId = (() => {
|
||||
const deviceId =
|
||||
'properties' in request.body.payload
|
||||
? request.body.payload.properties?.__deviceId
|
||||
: undefined;
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
if (profileId) {
|
||||
request.body.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (identity && Object.keys(identity).length > 1) {
|
||||
promises.push(
|
||||
identify({
|
||||
payload: identity,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
promises.push(
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
const geo = await getGeoLocation(ip);
|
||||
await identify({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
case 'increment': {
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
interface TrackContext {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua?: string;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: { value: number; isFromPast: boolean };
|
||||
identity?: IIdentifyPayload;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
async function track({
|
||||
payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
}: {
|
||||
payload: TrackPayload;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: number;
|
||||
isTimestampFromThePast: boolean;
|
||||
}) {
|
||||
async function buildContext(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
validatedBody: ITrackHandlerPayload
|
||||
): Promise<TrackContext> {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
throw new HttpError('Missing projectId', { status: 400 });
|
||||
}
|
||||
|
||||
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
|
||||
const ip =
|
||||
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
|
||||
? (validatedBody.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||
|
||||
const headers = getStringHeaders(request.headers);
|
||||
const identity = getIdentity(validatedBody);
|
||||
const profileId = identity?.profileId;
|
||||
|
||||
if (profileId && validatedBody.type === 'track') {
|
||||
validatedBody.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
// Get geo location (needed for track and identify)
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
|
||||
const { deviceId, sessionId } = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId:
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
headers,
|
||||
timestamp: {
|
||||
value: timestamp.timestamp,
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
deviceId,
|
||||
sessionId,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
|
||||
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
timestamp.value,
|
||||
projectId,
|
||||
deviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (context.identity && Object.keys(context.identity).length > 1) {
|
||||
promises.push(handleIdentify(context.identity, context));
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
deviceId,
|
||||
sessionId,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
groupId,
|
||||
jobId,
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function identify({
|
||||
payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}: {
|
||||
payload: IdentifyPayload;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
ua?: string;
|
||||
}) {
|
||||
async function handleIdentify(
|
||||
payload: IIdentifyPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { projectId, geo, ua } = context;
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
await upsertProfile({
|
||||
...payload,
|
||||
@@ -312,32 +255,30 @@ async function identify({
|
||||
});
|
||||
}
|
||||
|
||||
async function increment({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: IncrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
async function adjustProfileProperty(
|
||||
payload: IIncrementPayload | IDecrementPayload,
|
||||
projectId: string,
|
||||
direction: 1 | -1
|
||||
): Promise<void> {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
throw new HttpError('Profile not found', { status: 404 });
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
throw new HttpError('Property value is not a number', { status: 400 });
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed + (value || 1),
|
||||
profile.properties,
|
||||
parsed + direction * (value || 1),
|
||||
profile.properties
|
||||
);
|
||||
|
||||
await upsertProfile({
|
||||
@@ -348,45 +289,106 @@ async function increment({
|
||||
});
|
||||
}
|
||||
|
||||
async function decrement({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: DecrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
async function handleIncrement(
|
||||
payload: IIncrementPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, 1);
|
||||
}
|
||||
|
||||
async function handleDecrement(
|
||||
payload: IDecrementPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, -1);
|
||||
}
|
||||
|
||||
async function handleReplay(
|
||||
payload: IReplayPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
if (!context.sessionId) {
|
||||
throw new HttpError('Session ID is required for replay', { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
);
|
||||
const row = {
|
||||
project_id: context.projectId,
|
||||
session_id: context.sessionId,
|
||||
chunk_index: payload.chunk_index,
|
||||
started_at: payload.started_at,
|
||||
ended_at: payload.ended_at,
|
||||
events_count: payload.events_count,
|
||||
is_full_snapshot: payload.is_full_snapshot,
|
||||
payload: payload.payload,
|
||||
};
|
||||
await replayBuffer.add(row);
|
||||
}
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
// Validate request body with Zod
|
||||
const validationResult = zTrackHandlerPayload.safeParse(request.body);
|
||||
if (!validationResult.success) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Validation failed',
|
||||
errors: validationResult.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed - (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
const validatedBody = validationResult.data;
|
||||
|
||||
await upsertProfile({
|
||||
id: profile.id,
|
||||
projectId,
|
||||
properties: profile.properties,
|
||||
isExternal: true,
|
||||
// Handle alias (not supported)
|
||||
if (validatedBody.type === 'alias') {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
|
||||
// Build request context
|
||||
const context = await buildContext(request, validatedBody);
|
||||
|
||||
// Dispatch to appropriate handler
|
||||
switch (validatedBody.type) {
|
||||
case 'track':
|
||||
await handleTrack(validatedBody.payload, context);
|
||||
break;
|
||||
case 'identify':
|
||||
await handleIdentify(validatedBody.payload, context);
|
||||
break;
|
||||
case 'increment':
|
||||
await handleIncrement(validatedBody.payload, context);
|
||||
break;
|
||||
case 'decrement':
|
||||
await handleDecrement(validatedBody.payload, context);
|
||||
break;
|
||||
case 'replay':
|
||||
await handleReplay(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send({
|
||||
deviceId: context.deviceId,
|
||||
sessionId: context.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
@@ -419,20 +421,31 @@ export async function fetchDeviceId(
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = JSON.parse(res?.[0]?.[1] as string);
|
||||
const sessionId = data.payload.sessionId;
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
sessionId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = JSON.parse(res?.[1]?.[1] as string);
|
||||
const sessionId = data.payload.sessionId;
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
sessionId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
@@ -442,6 +455,7 @@ export async function fetchDeviceId(
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: '',
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@ export async function polarWebhook(
|
||||
.parse(event.data.metadata);
|
||||
|
||||
const product = await getProduct(event.data.productId);
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: metadata.organizationId,
|
||||
},
|
||||
});
|
||||
const eventsLimit = product.metadata?.eventsLimit;
|
||||
const subscriptionPeriodEventsLimit =
|
||||
typeof eventsLimit === 'number' ? eventsLimit : undefined;
|
||||
@@ -186,7 +191,9 @@ export async function polarWebhook(
|
||||
where: {
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionStatus: 'active',
|
||||
subscriptionStatus: {
|
||||
in: ['active', 'past_due', 'unpaid'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +223,13 @@ export async function polarWebhook(
|
||||
subscriptionCreatedByUserId: metadata.userId,
|
||||
subscriptionInterval: event.data.recurringInterval,
|
||||
subscriptionPeriodEventsLimit,
|
||||
subscriptionPeriodEventsCountExceededAt:
|
||||
subscriptionPeriodEventsLimit &&
|
||||
organization.subscriptionPeriodEventsCountExceededAt &&
|
||||
organization.subscriptionPeriodEventsLimit <
|
||||
subscriptionPeriodEventsLimit
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function clientHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const ip = req.clientIp;
|
||||
const origin = req.headers.origin;
|
||||
const clientId = req.headers['openpanel-client-id'];
|
||||
const shouldCheck = ip && origin && clientId;
|
||||
|
||||
const body = req?.body;
|
||||
const isTrackPayload = getIsTrackPayload(req);
|
||||
const isReplay = isTrackPayload && req.body.type === 'replay';
|
||||
const shouldCheck = ip && origin && clientId && !isReplay;
|
||||
const isDuplicate = shouldCheck
|
||||
? await isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload: req.body,
|
||||
payload: body,
|
||||
projectId: clientId as string,
|
||||
})
|
||||
: false;
|
||||
@@ -26,3 +31,25 @@ export async function duplicateHook(
|
||||
return reply.status(200).send('Duplicate event');
|
||||
}
|
||||
}
|
||||
|
||||
function getIsTrackPayload(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>
|
||||
): req is FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}> {
|
||||
if (req.method !== 'POST') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'type' in req.body;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type { TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
type DeprecatedEventPayload = {
|
||||
name: string;
|
||||
properties: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
};
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: TrackHandlerPayload | DeprecatedEventPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -46,6 +44,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send('OK');
|
||||
return reply.status(202).send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, pick } from 'ramda';
|
||||
|
||||
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
|
||||
const ignoreMethods = ['OPTIONS'];
|
||||
|
||||
const getTrpcInput = (
|
||||
request: FastifyRequest,
|
||||
request: FastifyRequest
|
||||
): Record<string, unknown> | undefined => {
|
||||
const input = path<any>(['query', 'input'], request);
|
||||
try {
|
||||
@@ -18,7 +17,7 @@ const getTrpcInput = (
|
||||
|
||||
export async function requestLoggingHook(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (ignoreMethods.includes(request.method)) {
|
||||
return;
|
||||
@@ -38,19 +37,10 @@ export async function requestLoggingHook(
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
elapsed: reply.elapsedTime,
|
||||
clientIp: request.clientIp,
|
||||
clientIpHeader: request.clientIpHeader,
|
||||
headers: pick(
|
||||
[
|
||||
'openpanel-client-id',
|
||||
'openpanel-sdk-name',
|
||||
'openpanel-sdk-version',
|
||||
'user-agent',
|
||||
...DEFAULT_IP_HEADER_ORDER,
|
||||
],
|
||||
request.headers,
|
||||
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
||||
request.headers
|
||||
),
|
||||
body: request.body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
import compress from '@fastify/compress';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||
import Fastify from 'fastify';
|
||||
import metricsPlugin from 'fastify-metrics';
|
||||
|
||||
import {
|
||||
decodeSessionToken,
|
||||
EMPTY_SESSION,
|
||||
type SessionValidationResult,
|
||||
validateSessionToken,
|
||||
} from '@openpanel/auth';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import {
|
||||
type IServiceClientWithProject,
|
||||
runWithAlsSession,
|
||||
} from '@openpanel/db';
|
||||
import { getCache, getRedisPub } from '@openpanel/redis';
|
||||
import { getRedisPub } from '@openpanel/redis';
|
||||
import type { AppRouter } from '@openpanel/trpc';
|
||||
import { appRouter, createContext } from '@openpanel/trpc';
|
||||
|
||||
import {
|
||||
EMPTY_SESSION,
|
||||
type SessionValidationResult,
|
||||
decodeSessionToken,
|
||||
validateSessionToken,
|
||||
} from '@openpanel/auth';
|
||||
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||
import Fastify from 'fastify';
|
||||
import metricsPlugin from 'fastify-metrics';
|
||||
import sourceMapSupport from 'source-map-support';
|
||||
import {
|
||||
healthcheck,
|
||||
@@ -35,9 +36,11 @@ import { timestampHook } from './hooks/timestamp.hook';
|
||||
import aiRouter from './routes/ai.router';
|
||||
import eventRouter from './routes/event.router';
|
||||
import exportRouter from './routes/export.router';
|
||||
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 manageRouter from './routes/manage.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
import profileRouter from './routes/profile.router';
|
||||
@@ -49,8 +52,6 @@ import { logger } from './utils/logger';
|
||||
|
||||
sourceMapSupport.install();
|
||||
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
client: IServiceClientWithProject | null;
|
||||
@@ -62,13 +63,16 @@ declare module 'fastify' {
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.API_PORT || '3000', 10);
|
||||
const host =
|
||||
process.env.API_HOST ||
|
||||
(process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost');
|
||||
|
||||
const startServer = async () => {
|
||||
logger.info('Starting server');
|
||||
try {
|
||||
const fastify = Fastify({
|
||||
maxParamLength: 15_000,
|
||||
bodyLimit: 1048576 * 500, // 500MB
|
||||
bodyLimit: 1_048_576 * 500, // 500MB
|
||||
loggerInstance: logger as unknown as FastifyBaseLogger,
|
||||
disableRequestLogging: true,
|
||||
genReqId: (req) =>
|
||||
@@ -80,7 +84,7 @@ const startServer = async () => {
|
||||
fastify.register(cors, () => {
|
||||
return (
|
||||
req: FastifyRequest,
|
||||
callback: (error: Error | null, options: FastifyCorsOptions) => void,
|
||||
callback: (error: Error | null, options: FastifyCorsOptions) => void
|
||||
) => {
|
||||
// TODO: set prefix on dashboard routes
|
||||
const corsPaths = [
|
||||
@@ -93,7 +97,7 @@ const startServer = async () => {
|
||||
];
|
||||
|
||||
const isPrivatePath = corsPaths.some((path) =>
|
||||
req.url.startsWith(path),
|
||||
req.url.startsWith(path)
|
||||
);
|
||||
|
||||
if (isPrivatePath) {
|
||||
@@ -114,6 +118,7 @@ const startServer = async () => {
|
||||
|
||||
return callback(null, {
|
||||
origin: '*',
|
||||
maxAge: 86_400 * 7, // cache preflight for 7 days
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -143,12 +148,21 @@ const startServer = async () => {
|
||||
instance.addHook('onRequest', async (req) => {
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const sessionId = decodeSessionToken(req.cookies.session);
|
||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||
const session = await runWithAlsSession(sessionId, () =>
|
||||
validateSessionToken(req.cookies.session),
|
||||
validateSessionToken(req.cookies.session)
|
||||
);
|
||||
req.session = session;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else if (process.env.DEMO_USER_ID) {
|
||||
try {
|
||||
const session = await runWithAlsSession('1', () =>
|
||||
validateSessionToken(null)
|
||||
);
|
||||
req.session = session;
|
||||
} catch {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else {
|
||||
@@ -160,7 +174,7 @@ const startServer = async () => {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: createContext,
|
||||
createContext,
|
||||
onError(ctx) {
|
||||
if (
|
||||
ctx.error.code === 'UNAUTHORIZED' &&
|
||||
@@ -181,6 +195,7 @@ const startServer = async () => {
|
||||
instance.register(liveRouter, { prefix: '/live' });
|
||||
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||
instance.register(miscRouter, { prefix: '/misc' });
|
||||
instance.register(aiRouter, { prefix: '/ai' });
|
||||
});
|
||||
@@ -194,6 +209,7 @@ const startServer = async () => {
|
||||
instance.register(importRouter, { prefix: '/import' });
|
||||
instance.register(insightsRouter, { prefix: '/insights' });
|
||||
instance.register(trackRouter, { prefix: '/track' });
|
||||
instance.register(manageRouter, { prefix: '/manage' });
|
||||
// Keep existing endpoints for backward compatibility
|
||||
instance.get('/healthcheck', healthcheck);
|
||||
// New Kubernetes-style health endpoints
|
||||
@@ -203,39 +219,50 @@ const startServer = async () => {
|
||||
reply.send({
|
||||
status: 'ok',
|
||||
message: 'Successfully running OpenPanel.dev API',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
|
||||
fastify.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof HttpError) {
|
||||
request.log.error(`${error.message}`, error);
|
||||
if (process.env.NODE_ENV === 'production' && error.status === 500) {
|
||||
request.log.error('request error', { error });
|
||||
reply.status(500).send('Internal server error');
|
||||
} else {
|
||||
reply.status(error.status).send({
|
||||
status: error.status,
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
} else if (error.statusCode === 429) {
|
||||
reply.status(429).send({
|
||||
if (error.statusCode === 429) {
|
||||
return reply.status(429).send({
|
||||
status: 429,
|
||||
error: 'Too Many Requests',
|
||||
message: 'You have exceeded the rate limit for this endpoint.',
|
||||
});
|
||||
} else if (error.statusCode === 400) {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error,
|
||||
message: 'The request was invalid.',
|
||||
});
|
||||
} else {
|
||||
request.log.error('request error', { error });
|
||||
reply.status(500).send('Internal server error');
|
||||
}
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
if (!SKIP_LOG_ERRORS.includes(error.code)) {
|
||||
request.log.error('internal server error', { error });
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && error.status === 500) {
|
||||
return reply.status(500).send('Internal server error');
|
||||
}
|
||||
|
||||
return reply.status(error.status).send({
|
||||
status: error.status,
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (!SKIP_LOG_ERRORS.includes(error.code)) {
|
||||
request.log.error('request error', { error });
|
||||
}
|
||||
|
||||
const status = error?.statusCode ?? 500;
|
||||
if (process.env.NODE_ENV === 'production' && status === 500) {
|
||||
return reply.status(500).send('Internal server error');
|
||||
}
|
||||
|
||||
return reply.status(status).send({
|
||||
status,
|
||||
error,
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@@ -252,10 +279,7 @@ const startServer = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.listen({
|
||||
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost',
|
||||
port,
|
||||
});
|
||||
await fastify.listen({ host, port });
|
||||
|
||||
try {
|
||||
// Notify when keys expires
|
||||
@@ -263,7 +287,7 @@ const startServer = async () => {
|
||||
} catch (error) {
|
||||
logger.warn('Failed to set redis notify-keyspace-events', error);
|
||||
logger.warn(
|
||||
'If you use a managed Redis service, you may need to set this manually.',
|
||||
'If you use a managed Redis service, you may need to set this manually.'
|
||||
);
|
||||
logger.warn('Otherwise some functions may not work as expected.');
|
||||
}
|
||||
|
||||
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
const router: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/callback',
|
||||
handler: gscGoogleCallback,
|
||||
});
|
||||
};
|
||||
|
||||
export default router;
|
||||
132
apps/api/src/routes/manage.router.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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({
|
||||
fastify,
|
||||
max: 20,
|
||||
timeWindow: '10 seconds',
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
try {
|
||||
const client = await validateManageRequest(req.headers);
|
||||
req.client = client;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return reply.status(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Client ID seems to be malformed',
|
||||
});
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: e.message });
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Projects routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects',
|
||||
handler: controller.listProjects,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects/:id',
|
||||
handler: controller.getProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/projects',
|
||||
handler: controller.createProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/projects/:id',
|
||||
handler: controller.updateProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/projects/:id',
|
||||
handler: controller.deleteProject,
|
||||
});
|
||||
|
||||
// Clients routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients',
|
||||
handler: controller.listClients,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients/:id',
|
||||
handler: controller.getClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/clients',
|
||||
handler: controller.createClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/clients/:id',
|
||||
handler: controller.updateClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/clients/:id',
|
||||
handler: controller.deleteClient,
|
||||
});
|
||||
|
||||
// References routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references',
|
||||
handler: controller.listReferences,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references/:id',
|
||||
handler: controller.getReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/references',
|
||||
handler: controller.createReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/references/:id',
|
||||
handler: controller.updateReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/references/:id',
|
||||
handler: controller.deleteReference,
|
||||
});
|
||||
};
|
||||
|
||||
export default manageRouter;
|
||||
@@ -14,22 +14,6 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['type', 'payload'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
|
||||
},
|
||||
payload: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
@@ -42,6 +26,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
sessionId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import { zReportInput } from '@openpanel/validation';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -27,7 +27,10 @@ export function getReport({
|
||||
- ${chartTypes.metric}
|
||||
- ${chartTypes.bar}
|
||||
`,
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -72,7 +75,10 @@ export function getConversionReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -94,7 +100,10 @@ export function getFunnelReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
|
||||
@@ -4,10 +4,11 @@ import { verifyPassword } from '@openpanel/common/server';
|
||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
IProjectFilterIp,
|
||||
IProjectFilterProfileId,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import { path } from 'ramda';
|
||||
|
||||
@@ -41,7 +42,7 @@ export class SdkAuthError extends Error {
|
||||
|
||||
export async function validateSdkRequest(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const { headers, clientIp } = req;
|
||||
@@ -72,7 +73,7 @@ export async function validateSdkRequest(
|
||||
clientId,
|
||||
)
|
||||
) {
|
||||
throw createError('Ingestion: Clean ID must be a valid UUIDv4');
|
||||
throw createError('Ingestion: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
@@ -235,3 +236,40 @@ export async function validateImportRequest(
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function validateManageRequest(
|
||||
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,
|
||||
)
|
||||
) {
|
||||
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
|
||||
if (!client) {
|
||||
throw new Error('Manage: Invalid client id');
|
||||
}
|
||||
|
||||
if (!client.secret) {
|
||||
throw new Error('Manage: Client has no secret');
|
||||
}
|
||||
|
||||
if (client.type !== ClientType.root) {
|
||||
throw new Error(
|
||||
'Manage: Only root clients are allowed to manage resources',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(clientSecret, client.secret))) {
|
||||
throw new Error('Manage: Invalid client secret');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
158
apps/api/src/utils/ids.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { generateDeviceId } from '@openpanel/common/server';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
|
||||
export async function getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua: string | undefined;
|
||||
salts: { current: string; previous: string };
|
||||
overrideDeviceId?: string;
|
||||
}) {
|
||||
if (overrideDeviceId) {
|
||||
return { deviceId: overrideDeviceId, sessionId: '' };
|
||||
}
|
||||
|
||||
if (!ua) {
|
||||
return { deviceId: '', sessionId: '' };
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
return await getDeviceIdFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function getDeviceIdFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
}) {
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
const res = await multi.exec();
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
(res?.[0]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: currentDeviceId, sessionId };
|
||||
}
|
||||
}
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
(res?.[1]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: previousDeviceId, sessionId };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: getSessionId({
|
||||
projectId,
|
||||
deviceId: currentDeviceId,
|
||||
graceMs: 5 * 1000,
|
||||
windowMs: 1000 * 60 * 30,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic session id for (projectId, deviceId) within a time window,
|
||||
* with a grace period at the *start* of each window to avoid boundary splits.
|
||||
*
|
||||
* - windowMs: 30 minutes by default
|
||||
* - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket)
|
||||
* - Output: base64url, 128-bit (16 bytes) truncated from SHA-256
|
||||
*/
|
||||
function getSessionId(params: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
eventMs?: number; // use event timestamp; defaults to Date.now()
|
||||
windowMs?: number; // default 5 min
|
||||
graceMs?: number; // default 1 min
|
||||
bytes?: number; // default 16 (128-bit). You can set 24 or 32 for longer ids.
|
||||
}): string {
|
||||
const {
|
||||
projectId,
|
||||
deviceId,
|
||||
eventMs = Date.now(),
|
||||
windowMs = 5 * 60 * 1000,
|
||||
graceMs = 60 * 1000,
|
||||
bytes = 16,
|
||||
} = params;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('projectId is required');
|
||||
}
|
||||
if (!deviceId) {
|
||||
throw new Error('deviceId is required');
|
||||
}
|
||||
if (windowMs <= 0) {
|
||||
throw new Error('windowMs must be > 0');
|
||||
}
|
||||
if (graceMs < 0 || graceMs >= windowMs) {
|
||||
throw new Error('graceMs must be >= 0 and < windowMs');
|
||||
}
|
||||
if (bytes < 8 || bytes > 32) {
|
||||
throw new Error('bytes must be between 8 and 32');
|
||||
}
|
||||
|
||||
const bucket = Math.floor(eventMs / windowMs);
|
||||
const offset = eventMs - bucket * windowMs;
|
||||
|
||||
// Grace at the start of the bucket: stick to the previous bucket.
|
||||
const chosenBucket = offset < graceMs ? bucket - 1 : bucket;
|
||||
|
||||
const input = `sess:v1:${projectId}:${deviceId}:${chosenBucket}`;
|
||||
|
||||
const digest = crypto.createHash('sha256').update(input).digest();
|
||||
const truncated = digest.subarray(0, bytes);
|
||||
|
||||
// base64url
|
||||
return truncated
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import urlMetadata from 'url-metadata';
|
||||
|
||||
function fallbackFavicon(url: string) {
|
||||
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
||||
} catch {
|
||||
// If URL parsing fails, use the original string
|
||||
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
|
||||
}
|
||||
}
|
||||
|
||||
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||
@@ -66,7 +72,9 @@ interface UrlMetaData {
|
||||
|
||||
export async function parseUrlMeta(url: string) {
|
||||
try {
|
||||
const metadata = (await urlMetadata(url)) as UrlMetaData;
|
||||
const metadata = (await urlMetadata(url, {
|
||||
timeout: 500,
|
||||
})) as UrlMetaData;
|
||||
const data = transform(metadata, url);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
BIN
apps/justfuckinguseopenpanel/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
505
apps/justfuckinguseopenpanel/index.html
Normal file
@@ -0,0 +1,505 @@
|
||||
<!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>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
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>
|
||||
</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}}) }();
|
||||
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>
|
||||
</html>
|
||||
BIN
apps/justfuckinguseopenpanel/ogimage.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/overview-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/profile-dark.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/report-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
7
apps/justfuckinguseopenpanel/wrangler.jsonc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
2
apps/public/.gitignore
vendored
@@ -2,8 +2,6 @@
|
||||
/node_modules
|
||||
|
||||
# generated content
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
|
||||
# test & build
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
ARG NODE_VERSION=20.15.1
|
||||
|
||||
FROM --platform=linux/amd64 node:${NODE_VERSION}-slim AS base
|
||||
|
||||
ARG DATABASE_URL
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG REDIS_URL
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ARG CLICKHOUSE_URL
|
||||
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/public/package.json apps/public/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/redis/package.json packages/redis/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/common/package.json packages/common/package.json
|
||||
COPY packages/constants/package.json packages/constants/package.json
|
||||
COPY packages/validation/package.json packages/validation/package.json
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
|
||||
COPY packages/sdks/_info/package.json packages/sdks/_info/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps/public apps/public
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/public /app/apps/public
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/redis /app/packages/redis
|
||||
COPY --from=build /app/packages/common /app/packages/common
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
COPY --from=build /app/packages/constants /app/packages/constants
|
||||
COPY --from=build /app/packages/validation /app/packages/validation
|
||||
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
|
||||
COPY --from=build /app/packages/sdks/_info /app/packages/sdks/_info
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
|
||||
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -15,6 +15,25 @@ yarn dev
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js and Fumadocs, take a look at the following
|
||||
@@ -23,4 +42,4 @@ resources:
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||
features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
|
||||
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { url, getAuthor } from '@/app/layout.config';
|
||||
import { SingleSwirl } from '@/components/Swirls';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { SectionHeader } from '@/components/section';
|
||||
import { Toc } from '@/components/toc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const author = getAuthor(article?.data.team);
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
title: 'Article Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
authors: [{ name: author.name }],
|
||||
alternates: {
|
||||
canonical: url(article.url),
|
||||
},
|
||||
openGraph: {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
type: 'article',
|
||||
publishedTime: article.data.date.toISOString(),
|
||||
authors: author.name,
|
||||
images: url(article.data.cover),
|
||||
url: url(article.url),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
images: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}) {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const Body = article?.data.body;
|
||||
const author = getAuthor(article?.data.team);
|
||||
const goBackUrl = '/articles';
|
||||
|
||||
const relatedArticles = (await articleSource.getPages())
|
||||
.filter(
|
||||
(item) =>
|
||||
item.data.tag === article?.data.tag && item.url !== article?.url,
|
||||
)
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
if (!Body) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Create the JSON-LD data
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article?.data.title,
|
||||
datePublished: article?.data.date.toISOString(),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url(article.url),
|
||||
},
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="article-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<article className="container max-w-5xl col">
|
||||
<div className="py-16">
|
||||
<Link
|
||||
href={goBackUrl}
|
||||
className="flex items-center gap-2 mb-4 text-muted-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
<span>Back to all articles</span>
|
||||
</Link>
|
||||
<div className="flex-col-reverse col md:row gap-8">
|
||||
<div className="col flex-1">
|
||||
<h1 className="text-5xl font-bold leading-tight">
|
||||
{article?.data.title}
|
||||
</h1>
|
||||
|
||||
<div className="row gap-4 items-center mt-8">
|
||||
<div className="size-10 center-center bg-black rounded-full">
|
||||
{author.image ? (
|
||||
<Image
|
||||
className="size-10 object-cover rounded-full"
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
) : (
|
||||
<Logo className="w-6 h-6 fill-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="col">
|
||||
<p className="font-medium">{author.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{article?.data.date.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
|
||||
<div className="min-w-0">
|
||||
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
|
||||
<Body />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="pl-12 pb-12 gap-8 col">
|
||||
<Toc toc={article?.data.toc} />
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl py-16">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0 size-[300px]" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50 size-[300px]" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
title="Try it"
|
||||
description="Give it a spin for free. No credit card required."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{relatedArticles.length > 0 && (
|
||||
<div className="my-16">
|
||||
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{relatedArticles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
const title = 'Articles';
|
||||
const description = 'Read our latest articles';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: url('/articles'),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const articles = (await articleSource.getPages()).sort(
|
||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="container col">
|
||||
<div className="py-16">
|
||||
<h1 className="text-center text-7xl font-bold">Articles</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{articles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="overflow-hidden">
|
||||
<HeroContainer className="h-screen pointer-events-none" />
|
||||
<div className="absolute h-screen inset-0 radial-gradient-dot-pages select-none pointer-events-none" />
|
||||
<div className="-mt-[calc(100vh-100px)] relative min-h-[500px] pb-12 -mb-24">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { SupporterPerks } from '@/components/sections/supporter-perks';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
HeartHandshakeIcon,
|
||||
SparklesIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
alternates: {
|
||||
canonical: url('/supporter'),
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
type: 'website',
|
||||
url: url('/supporter'),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
},
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Become a Supporter',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url('/supporter'),
|
||||
},
|
||||
};
|
||||
|
||||
export default function SupporterPage() {
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
id="supporter-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 text-center">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-center">
|
||||
<HeartHandshakeIcon className="size-4 text-rose-600" />
|
||||
Support Open-Source Analytics
|
||||
</Tag>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold leading-[1.1]">
|
||||
Help us build the future of{' '}
|
||||
<span className="text-primary">open analytics</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Your support accelerates development, funds infrastructure, and
|
||||
helps us build features faster. Plus, you get exclusive perks
|
||||
and early access to everything we ship.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-4 justify-center items-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter
|
||||
<SparklesIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting at $20/month • Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<div className="container max-w-7xl">
|
||||
{/* Main Content with Sidebar */}
|
||||
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
|
||||
{/* Main Content */}
|
||||
<div className="col gap-12">
|
||||
{/* Why Support Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">Why your support matters</h2>
|
||||
<div className="col gap-6 text-muted-foreground">
|
||||
<p className="text-lg">
|
||||
We're not a big corporation – just a small team passionate
|
||||
about building something useful for developers. OpenPanel
|
||||
started because we believed analytics tools shouldn't be
|
||||
complicated or locked behind expensive enterprise
|
||||
subscriptions.
|
||||
</p>
|
||||
<p>When you become a supporter, you're directly funding:</p>
|
||||
<ul className="col gap-3 list-none pl-0">
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Active Development
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
More time fixing bugs, adding features, and improving
|
||||
documentation
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Infrastructure
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Keeping servers running, CI/CD pipelines, and
|
||||
development tools
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">Independence</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Staying focused on what matters: building a tool
|
||||
developers actually want
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
No corporate speak, no fancy promises – just honest work on
|
||||
making OpenPanel better for everyone. Every contribution, no
|
||||
matter the size, helps us stay independent and focused on what
|
||||
matters.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What You Get Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">
|
||||
What you get as a supporter
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
🚀 Latest Docker Images
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get bleeding-edge builds on every commit. Access new
|
||||
features weeks before public release.
|
||||
</p>
|
||||
<Link
|
||||
href="/docs/self-hosting/supporter-access-latest-docker-images"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
💬 Prioritized Support
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get help faster with priority support in our Discord
|
||||
community. Your questions get answered first.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
✨ Feature Requests
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Your ideas and feature requests get prioritized in our
|
||||
roadmap. Shape the future of OpenPanel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
⭐ Exclusive Discord Role
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Special badge and recognition in our community. Show your
|
||||
support with pride.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Impact Section */}
|
||||
<section className="p-8 rounded-xl border bg-gradient-to-br from-primary/5 to-primary/10">
|
||||
<h2 className="text-2xl font-bold mb-4">Your impact</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Every dollar you contribute goes directly into development,
|
||||
infrastructure, and making OpenPanel better. Here's what your
|
||||
support enables:
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
100%
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Open Source
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
24/7
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Active Development
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">∞</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Self-Hostable
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:block hidden">
|
||||
<SupporterPerks />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Mobile Perks */}
|
||||
<div className="lg:hidden mb-16">
|
||||
<SupporterPerks />
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
Starting at $20/month
|
||||
</Tag>
|
||||
}
|
||||
title="Ready to support OpenPanel?"
|
||||
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
|
||||
/>
|
||||
<div className="center-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter Now
|
||||
<HeartHandshakeIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {
|
||||
createNextRouteHandler,
|
||||
createScriptHandler,
|
||||
} from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
export const GET = createScriptHandler();
|
||||
@@ -1,4 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -1,67 +0,0 @@
|
||||
import { url, siteName } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from 'fumadocs-ui/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
canonical: url(page.url),
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
url: url(page.url),
|
||||
type: 'article',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--green: 156 71% 67%;
|
||||
--red: 351 89% 72%;
|
||||
--background: 0 0% 98%;
|
||||
--background-light: 0 0% 100%;
|
||||
--background-dark: 0 0% 96%;
|
||||
--foreground: 0 0% 9%;
|
||||
--foreground-dark: 0 0% 7.5%;
|
||||
--foreground-light: 0 0% 11%;
|
||||
--card: 0 0% 98%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 98%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 9%;
|
||||
--background-dark: 0 0% 7.5%;
|
||||
--background-light: 0 0% 11%;
|
||||
--foreground: 0 0% 98%;
|
||||
--foreground-light: 0 0% 100%;
|
||||
--foreground-dark: 0 0% 96%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply !bg-[hsl(var(--background))] text-foreground font-sans text-base antialiased flex flex-col min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-6 md:px-10 lg:px-14 w-full;
|
||||
}
|
||||
|
||||
.pulled {
|
||||
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
|
||||
}
|
||||
|
||||
.row {
|
||||
@apply flex flex-row;
|
||||
}
|
||||
|
||||
.col {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.center-center {
|
||||
@apply flex items-center justify-center text-center;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
|
||||
.radial-gradient {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at bottom, hsl(var(--background-light)), hsl(var(--background)));
|
||||
}
|
||||
|
||||
.radial-gradient-dot-1 {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 20%, hsl(var(--background-light)), transparent);
|
||||
}
|
||||
|
||||
.radial-gradient-dot-pages {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 50%, hsl(var(--background)), hsl(var(--background)/0.2));
|
||||
}
|
||||
|
||||
.animated-iframe-gradient {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.animated-iframe-gradient:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
background: linear-gradient(250deg, hsl(var(--foreground)/0.9), transparent);
|
||||
|
||||
animation: GradientRotate 8s linear infinite;
|
||||
}
|
||||
@keyframes GradientRotate {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.line-before {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-before:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
.line-after {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-after:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
right: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: animateFadeUp 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeUp {
|
||||
0% { transform: translateY(0.5rem); scale: 0.95; }
|
||||
100% { transform: translateY(0); scale: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-down {
|
||||
animation: animateFadeDown 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeDown {
|
||||
0% { transform: translateY(-1rem); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Docs */
|
||||
|
||||
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: hsl(var(--background-dark));
|
||||
border: 1px solid hsl(var(--background-light));
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] pre{
|
||||
max-height: none;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
/**
|
||||
* Shared layout configurations
|
||||
*
|
||||
* you can configure layouts individually from:
|
||||
* Home Layout: app/(home)/layout.tsx
|
||||
* Docs Layout: app/docs/layout.tsx
|
||||
*/
|
||||
|
||||
export const siteName = 'OpenPanel';
|
||||
export const baseUrl = 'https://openpanel.dev';
|
||||
export const url = (path: string) => `${baseUrl}${path}`;
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: siteName,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Home',
|
||||
url: '/',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Pricing',
|
||||
url: '/pricing',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Supporter',
|
||||
url: '/supporter',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Documentation',
|
||||
url: '/docs',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Articles',
|
||||
url: '/articles',
|
||||
active: 'nested-url',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export const getAuthor = (author?: string) => {
|
||||
return authors.find((a) => a.name === author)!;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import './global.css';
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||
import { cn } from 'fumadocs-ui/components/api';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import Script from 'next/script';
|
||||
import { url, baseUrl, siteName } from './layout.config';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
|
||||
],
|
||||
};
|
||||
|
||||
const description = `${siteName} is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: siteName,
|
||||
template: `%s | ${siteName}`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical: baseUrl,
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
title: siteName,
|
||||
description,
|
||||
siteName: siteName,
|
||||
url: baseUrl,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={cn(GeistSans.variable, GeistMono.variable)}>
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op"
|
||||
cdnUrl="/api/op/op1.js"
|
||||
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
trackAttributes
|
||||
trackScreenViews
|
||||
trackOutgoingLinks
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { metadata } from './layout';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: metadata.title as string,
|
||||
short_name: 'Openpanel.dev',
|
||||
description: metadata.description!,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#fff',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function NotFound({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<HeroContainer className="h-screen center-center">
|
||||
<div className="relative z-10 col gap-2">
|
||||
<div className="text-[150px] font-mono font-bold opacity-5 -mb-4">
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold">Not Found</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Awkward, we couldn't find what you were looking for.
|
||||
</p>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { Hero } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { Features } from '@/components/sections/features';
|
||||
import { Pricing } from '@/components/sections/pricing';
|
||||
import { Sdks } from '@/components/sections/sdks';
|
||||
import { Stats, StatsPure } from '@/components/sections/stats';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { WhyOpenPanel } from '@/components/why-openpanel';
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OpenPanel | An open-source alternative to Mixpanel',
|
||||
};
|
||||
|
||||
// export const experimental_ppr = true;
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
<Pricing />
|
||||
<Sdks />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { articleSource, pageSource, source } from '@/lib/source';
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { url } from './layout.config';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const articles = await articleSource.getPages();
|
||||
const docs = await source.getPages();
|
||||
const pages = await pageSource.getPages();
|
||||
return [
|
||||
{
|
||||
url: url('/'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: url('/docs'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: url('/articles'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url('/supporter'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
...articles.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.lastModified,
|
||||
changeFrequency: 'yearly' as const,
|
||||
priority: 0.5,
|
||||
})),
|
||||
...docs.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.lastModified,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
...pages.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.lastModified,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image, { type ImageProps } from 'next/image';
|
||||
|
||||
type SwirlProps = Omit<ImageProps, 'src' | 'alt'>;
|
||||
|
||||
export function SingleSwirl({ className, ...props }: SwirlProps) {
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
src="/swirl-2.png"
|
||||
alt="Swirl"
|
||||
className={cn(
|
||||
'pointer-events-none w-full h-full object-cover',
|
||||
className,
|
||||
)}
|
||||
width={1200}
|
||||
height={1200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DoubleSwirl({ className, ...props }: SwirlProps) {
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
src="/swirl.png"
|
||||
alt="Swirl"
|
||||
className={cn(
|
||||
'pointer-events-none w-full h-full object-cover',
|
||||
className,
|
||||
)}
|
||||
width={1200}
|
||||
height={1200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from './ui/accordion';
|
||||
|
||||
export const Faqs = ({ children }: { children: React.ReactNode }) => (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center border rounded-lg [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
|
||||
>
|
||||
{children}
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
export const FaqItem = ({
|
||||
question,
|
||||
children,
|
||||
}: { question: string; children: string }) => (
|
||||
<AccordionItem
|
||||
value={question}
|
||||
itemScope
|
||||
itemProp="mainEntity"
|
||||
itemType="https://schema.org/Question"
|
||||
className="[&_[role=region]]:px-4"
|
||||
>
|
||||
<AccordionTrigger className="text-left" itemProp="name">
|
||||
{question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
itemProp="acceptedAnswer"
|
||||
itemScope
|
||||
itemType="https://schema.org/Answer"
|
||||
>
|
||||
{children}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
@@ -1,160 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, ConeIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function SmallFeature({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background-light rounded-lg p-1 border border-border group',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-background-dark rounded-lg p-8 h-full group-hover:bg-background-light transition-colors">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Feature({
|
||||
children,
|
||||
media,
|
||||
reverse = false,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
media?: React.ReactNode;
|
||||
reverse?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border rounded-lg bg-background-light overflow-hidden p-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 md:grid-cols-2 gap-4 items-center',
|
||||
!media && '!grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<div className={cn(reverse && 'md:order-last', 'p-10')}>{children}</div>
|
||||
{media && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background-dark h-full rounded-md overflow-hidden',
|
||||
reverse && 'md:order-first',
|
||||
)}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureContent({
|
||||
icon,
|
||||
title,
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
content: React.ReactNode[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{icon && (
|
||||
<div
|
||||
data-icon
|
||||
className="bg-foreground text-background rounded-md p-4 inline-block mb-6 transition-colors"
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-medium mb-2">{title}</h2>
|
||||
<div className="col gap-2">
|
||||
{content.map((c, i) => (
|
||||
<p className="text-muted-foreground" key={i.toString()}>
|
||||
{c}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureListItem({
|
||||
icon: Icon,
|
||||
title,
|
||||
}: { icon: React.ComponentType<any>; title: string }) {
|
||||
return (
|
||||
<div className="row items-center gap-2" key="funnel">
|
||||
<Icon className="size-4 text-foreground/70" strokeWidth={1.5} /> {title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureList({
|
||||
title,
|
||||
items,
|
||||
className,
|
||||
cols = 2,
|
||||
}: {
|
||||
title: string;
|
||||
items: React.ReactNode[];
|
||||
className?: string;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="font-semibold text-sm mb-2">{title}</h3>
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-2 [&>div]:p-2 [&>div]:row [&>div]:items-center [&>div]:gap-2 grid',
|
||||
cols === 1 && 'grid-cols-1',
|
||||
cols === 2 && 'grid-cols-2',
|
||||
cols === 3 && 'grid-cols-3',
|
||||
)}
|
||||
>
|
||||
{items.map((i, j) => (
|
||||
<div key={j.toString()}>{i}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureMore({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'font-medium items-center row justify-between border-t py-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children} <ChevronRightIcon className="size-4" strokeWidth={1.5} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { SingleSwirl } from './Swirls';
|
||||
import { Logo } from './logo';
|
||||
import { SectionHeader } from './section';
|
||||
import { Tag } from './tag';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div className="mt-32">
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
tag={<Tag>Discover User Insights</Tag>}
|
||||
title="Effortless web & product analytics"
|
||||
description="Simplify your web & product analytics with our user-friendly platform. Collect, analyze, and optimize your data in minutes."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="pt-32 text-sm relative overflow-hidden">
|
||||
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
|
||||
<Logo className="w-[900px] shrink-0" />
|
||||
</div>
|
||||
<div className="container grid grid-cols-1 md:grid-cols-5 gap-12 md:gap-8 relative">
|
||||
<div>
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions.nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Useful links</h3>
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
<li>
|
||||
<Link href="/pricing">Pricing</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/about">About</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact">Contact</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/supporter">Become a supporter</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3 ">
|
||||
<h3 className="font-medium">Articles</h3>
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
<li>
|
||||
<Link href="/articles/vs-mixpanel">OpenPanel vs Mixpanel</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/articles/mixpanel-alternatives">
|
||||
Mixpanel alternatives
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 md:items-end col gap-4">
|
||||
<div className="[&_svg]:size-6 row gap-4">
|
||||
<Link
|
||||
title="Go to GitHub"
|
||||
href="https://github.com/Openpanel-dev/openpanel"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Go to X"
|
||||
href="https://x.com/openpaneldev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>X</title>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Join Discord"
|
||||
href="https://go.openpanel.dev/discord"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Send an email"
|
||||
href="mailto:hello@openpanel.dev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<MailIcon className="size-6" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://status.openpanel.dev"
|
||||
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<span>Operational</span>
|
||||
<div className="size-2 bg-emerald-500 rounded-full" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground border-t pt-4 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||
<div className="container col md:row justify-between">
|
||||
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
|
||||
<div className="col lg:row gap-2 md:gap-4">
|
||||
<Link href="/sitemap.xml">Sitemap</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/terms">Terms of Service</Link>
|
||||
<Link href="/cookies">Cookie Policy (just kidding)</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import NextImage from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Frame = {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
Component: React.ComponentType;
|
||||
};
|
||||
|
||||
function LivePreview() {
|
||||
return (
|
||||
<iframe
|
||||
src={
|
||||
'https://dashboard.openpanel.dev/share/overview/zef2XC?header=0&range=30d'
|
||||
}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Image({ src }: { src: string }) {
|
||||
return (
|
||||
<div>
|
||||
<NextImage
|
||||
className="w-full h-full block dark:hidden"
|
||||
src={`/${src}-light.png`}
|
||||
alt={`${src} light`}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
<NextImage
|
||||
className="w-full h-full hidden dark:block"
|
||||
src={`/${src}-dark.png`}
|
||||
alt={`${src} dark`}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroCarousel() {
|
||||
const frames: Frame[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
key: 'overview',
|
||||
label: 'Live preview',
|
||||
Component: LivePreview,
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
key: 'analytics',
|
||||
label: 'Product analytics',
|
||||
Component: () => <Image src="dashboard" />,
|
||||
},
|
||||
{
|
||||
id: 'funnels',
|
||||
key: 'funnels',
|
||||
label: 'Funnels',
|
||||
Component: () => <Image src="funnel" />,
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
key: 'retention',
|
||||
label: 'Retention',
|
||||
Component: () => <Image src="retention" />,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
key: 'profile',
|
||||
label: 'Inspect profile',
|
||||
Component: () => <Image src="profile" />,
|
||||
},
|
||||
];
|
||||
|
||||
const [activeFrames, setActiveFrames] = useState<Frame[]>([frames[0]]);
|
||||
const activeFrame = activeFrames[activeFrames.length - 1];
|
||||
|
||||
return (
|
||||
<div className="col gap-6 w-full">
|
||||
<div className="flex-wrap row gap-x-4 gap-y-2 justify-center [&>div]:font-medium mt-1">
|
||||
{frames.map((frame) => (
|
||||
<div key={frame.id} className="relative">
|
||||
<Button
|
||||
variant="naked"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeFrame.id === frame.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFrame = {
|
||||
...frame,
|
||||
key: Math.random().toString().slice(2, 11),
|
||||
};
|
||||
|
||||
setActiveFrames((p) => [...p.slice(-2), newFrame]);
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{frame.label}
|
||||
</Button>
|
||||
<motion.div
|
||||
className="h-1 bg-foreground rounded-full"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: activeFrame.id === frame.id ? '100%' : '0%',
|
||||
opacity: activeFrame.id === frame.id ? 1 : 0,
|
||||
}}
|
||||
whileHover={{
|
||||
width: '100%',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pulled animated-iframe-gradient p-px pb-0 rounded-t-xl">
|
||||
<div className="overflow-hidden rounded-xl rounded-b-none w-full bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full h-[750px]',
|
||||
activeFrame.id !== 'overview' && 'h-auto aspect-[5/3]',
|
||||
)}
|
||||
>
|
||||
{activeFrames.slice(-1).map((frame) => (
|
||||
<div key={frame.key} className="absolute inset-0 w-full h-full">
|
||||
<div className="bg-background rounded-xl h-full w-full">
|
||||
<frame.Component />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { WorldMap } from './world-map';
|
||||
|
||||
export function HeroMap() {
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
const y = useTransform(scrollY, [0, 250], [0, 50], { clamp: true });
|
||||
const scale = useTransform(scrollY, [0, 250], [1, 1.1], { clamp: true });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={{ y, scale }}
|
||||
className="absolute inset-0 top-20 center-center items-start select-none"
|
||||
>
|
||||
<div className="min-w-[1400px] w-full">
|
||||
<WorldMap />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ChevronRightIcon,
|
||||
CookieIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
FlaskRoundIcon,
|
||||
GithubIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Competition } from './competition';
|
||||
import { Tag } from './tag';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:row sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-start">
|
||||
<StarIcon className="size-4 fill-yellow-500 text-yellow-500" />
|
||||
Trusted by +2000 projects
|
||||
</Tag>
|
||||
<h1
|
||||
className="text-4xl md:text-5xl font-extrabold leading-[1.1]"
|
||||
title="An open-source alternative to Mixpanel"
|
||||
>
|
||||
An open-source alternative to <Competition />
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines
|
||||
the power of Mixpanel with the ease of Plausible and one of the
|
||||
best Google Analytics replacements.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" asChild className="group w-72">
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started now
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{perks.map((perk) => (
|
||||
<li key={perk.text} className="text-sm text-muted-foreground">
|
||||
<perk.icon className="size-4 inline-block mr-1" />
|
||||
{perk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col sm:w-1/2 relative group">
|
||||
<div
|
||||
className={cn([
|
||||
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
|
||||
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-32',
|
||||
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
|
||||
])}
|
||||
>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
{/* URL bar */}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background rounded-md border border-border flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground flex-1">
|
||||
https://demo.openpanel.dev
|
||||
</span>
|
||||
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 top-12 center-center group-hover:bg-foreground/20 transition-colors">
|
||||
<Button
|
||||
asChild
|
||||
className="group-hover:opacity-100 opacity-0 transition-opacity pointer-events-auto"
|
||||
>
|
||||
<Link
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
Test live demo
|
||||
<FlaskRoundIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className={cn('radial-gradient overflow-hidden relative')}>
|
||||
<div className={cn('relative z-10', className)}>{children}</div>
|
||||
|
||||
{/* Shadow bottom */}
|
||||
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PlusLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('absolute', className)}>
|
||||
<div className="relative">
|
||||
<div className="w-px h-8 bg-foreground/40 -bottom-4 left-0 absolute animate-pulse" />
|
||||
<div className="w-8 h-px bg-foreground/40 -bottom-px -left-4 absolute animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerticalLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-px bg-gradient-to-t from-transparent via-foreground/30 to-transparent absolute -top-12 -bottom-12',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px bg-gradient-to-r from-transparent via-foreground/30 to-transparent absolute left-0 right-0',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { GithubButton } from './github-button';
|
||||
import { Logo } from './logo';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const navbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If click outside of the menu, close it
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleClick);
|
||||
return () => window.removeEventListener('click', handleClick);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<nav className="fixed top-4 z-50 w-full" ref={navbarRef}>
|
||||
<div className="container">
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between border border-transparent backdrop-blur-lg items-center p-4 -mx-4 rounded-full transition-colors',
|
||||
isScrolled
|
||||
? 'bg-background/90 border-foreground/10'
|
||||
: 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions.nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row items-center gap-8">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8 text-sm">
|
||||
{baseOptions.links?.map((link) => {
|
||||
if (link.type !== 'main') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground font-medium"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubButton />
|
||||
{/* Sign in button */}
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="hidden md:flex"
|
||||
href="https://dashboard.openpanel.dev/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="md:hidden -my-2"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen((p) => !p);
|
||||
}}
|
||||
>
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden -mx-4"
|
||||
>
|
||||
<div className="rounded-xl bg-background/90 border border-foreground/10 mt-4 md:hidden backdrop-blur-lg">
|
||||
<div className="col text-sm divide-y divide-foreground/10">
|
||||
{baseOptions.links?.map((link) => {
|
||||
if (link.type !== 'main') return null;
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground text-xl font-medium p-4 px-4"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <section className={cn('my-32 col', className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
tag?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('col gap-4 center-center mb-16', className)}>
|
||||
{tag}
|
||||
<h2 className="text-4xl font-medium">{title}</h2>
|
||||
<p className="text-muted-foreground max-w-screen-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ShieldQuestionIcon } from 'lucide-react';
|
||||
import Script from 'next/script';
|
||||
import { Section, SectionHeader } from '../section';
|
||||
import { Tag } from '../tag';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '../ui/accordion';
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: 'Does OpenPanel have a free tier?',
|
||||
answer: [
|
||||
'For our Cloud plan we offer a 30 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
|
||||
'OpenPanel is also open-source and you can self-host it for free!',
|
||||
'',
|
||||
'Why does OpenPanel not have a free tier?',
|
||||
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Is everything really unlimited?',
|
||||
answer: [
|
||||
'Everything except the amount of events is unlimited.',
|
||||
'We do not limit the amount of users, projects, dashboards, etc. We want a transparent and fair pricing model and we think unlimited is the best way to do this.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between web and product analytics?',
|
||||
answer: [
|
||||
'Web analytics focuses on website traffic metrics like page views, bounce rates, and visitor sources. Product analytics goes deeper into user behavior, tracking specific actions, user journeys, and feature usage within your application.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Do I need to modify my code to use OpenPanel?',
|
||||
answer: [
|
||||
'Minimal setup is required. Simply add our lightweight JavaScript snippet to your website or use one of our SDKs for your preferred framework. Most common frameworks like React, Vue, and Next.js are supported.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Is my data GDPR compliant?',
|
||||
answer: [
|
||||
'Yes, OpenPanel is fully GDPR compliant. We collect only essential data, do not use cookies for tracking, and provide tools to help you maintain compliance with privacy regulations.',
|
||||
'You can self-host OpenPanel to keep full control of your data.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Mixpanel?',
|
||||
answer: [
|
||||
'OpenPanel offers most of Mixpanel report features such as funnels, retention and visualizations of your data. If you miss something, please let us know. The biggest difference is that OpenPanel offers better web analytics.',
|
||||
'Other than that OpenPanel is way cheaper and can also be self-hosted.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Plausible?',
|
||||
answer: [
|
||||
`OpenPanel's web analytics is inspired by Plausible like many other analytics tools. The difference is that OpenPanel offers more tools for product analytics and better support for none web devices (iOS,Android and servers).`,
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Google Analytics?',
|
||||
answer: [
|
||||
'OpenPanel offers a more privacy-focused, user-friendly alternative to Google Analytics. We provide real-time data, no sampling, and more intuitive product analytics features.',
|
||||
'Unlike GA4, our interface is designed to be simple yet powerful, making it easier to find the insights you need.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I export my data?',
|
||||
answer: [
|
||||
'Currently you can export your data with our API. Depending on how many events you have this can be an issue.',
|
||||
'We are working on better export options and will be finished around Q1 2025.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What kind of support do you offer?',
|
||||
answer: ['Currently we offer support through GitHub and Discord.'],
|
||||
},
|
||||
];
|
||||
|
||||
export default Faq;
|
||||
export function Faq() {
|
||||
// Create the JSON-LD structured data
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: questions.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: q.answer.join(' '),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
{/* Add the JSON-LD script */}
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
|
||||
Get answers today
|
||||
</Tag>
|
||||
}
|
||||
title="FAQ"
|
||||
description="Some of the most common questions we get asked."
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center"
|
||||
>
|
||||
{questions.map((q) => (
|
||||
<AccordionItem value={q.question} key={q.question}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="max-w-2xl col gap-2">
|
||||
{q.answer.map((a) => (
|
||||
<p key={a}>{a}</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import {
|
||||
Feature,
|
||||
FeatureContent,
|
||||
FeatureList,
|
||||
FeatureListItem,
|
||||
FeatureMore,
|
||||
SmallFeature,
|
||||
} from '@/components/feature';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import {
|
||||
ActivityIcon,
|
||||
AreaChartIcon,
|
||||
BarChart2Icon,
|
||||
BarChartIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CloudIcon,
|
||||
ConeIcon,
|
||||
CookieIcon,
|
||||
DatabaseIcon,
|
||||
GithubIcon,
|
||||
LayersIcon,
|
||||
LineChartIcon,
|
||||
LockIcon,
|
||||
MapIcon,
|
||||
PieChartIcon,
|
||||
ServerIcon,
|
||||
Share2Icon,
|
||||
ShieldIcon,
|
||||
UserIcon,
|
||||
WalletIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import { BatteryIcon } from '../battery-icon';
|
||||
import { EventsFeature } from './features/events-feature';
|
||||
import { ProductAnalyticsFeature } from './features/product-analytics-feature';
|
||||
import { ProfilesFeature } from './features/profiles-feature';
|
||||
import { WebAnalyticsFeature } from './features/web-analytics-feature';
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
tag={
|
||||
<Tag>
|
||||
<BatteryIcon className="size-4" strokeWidth={1.5} />
|
||||
Batteries included
|
||||
</Tag>
|
||||
}
|
||||
title="Everything you need"
|
||||
description="We have combined the best features from the most popular analytics tools into one simple to use platform."
|
||||
/>
|
||||
<div className="col gap-16">
|
||||
<Feature media={<WebAnalyticsFeature />}>
|
||||
<FeatureContent
|
||||
title="Web analytics"
|
||||
content={[
|
||||
'Privacy-friendly analytics with all the important metrics you need, in a simple and modern interface.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Get a quick overview"
|
||||
items={[
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Visitors" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Referrals" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Top pages" />,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Top entries"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Top exists"
|
||||
/>,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Devices" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Sessions" />,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Bounce rate"
|
||||
/>,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Duration" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Geography" />,
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
|
||||
<Feature reverse media={<ProductAnalyticsFeature />}>
|
||||
<FeatureContent
|
||||
title="Product analytics"
|
||||
content={[
|
||||
'Turn data into decisions with powerful visualizations and real-time insights.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Understand your product"
|
||||
items={[
|
||||
<FeatureListItem key="funnel" icon={ConeIcon} title="Funnel" />,
|
||||
<FeatureListItem
|
||||
key="retention"
|
||||
icon={UserIcon}
|
||||
title="Retention"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="bar"
|
||||
icon={BarChartIcon}
|
||||
title="A/B tests"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="pie"
|
||||
icon={PieChartIcon}
|
||||
title="Conversion"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Supported charts"
|
||||
items={[
|
||||
<FeatureListItem key="line" icon={LineChartIcon} title="Line" />,
|
||||
<FeatureListItem key="bar" icon={BarChartIcon} title="Bar" />,
|
||||
<FeatureListItem key="pie" icon={PieChartIcon} title="Pie" />,
|
||||
<FeatureListItem key="area" icon={AreaChartIcon} title="Area" />,
|
||||
<FeatureListItem
|
||||
key="histogram"
|
||||
icon={BarChart2Icon}
|
||||
title="Histogram"
|
||||
/>,
|
||||
<FeatureListItem key="map" icon={MapIcon} title="Map" />,
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
|
||||
<FeatureContent
|
||||
icon={<ClockIcon className="size-8" strokeWidth={1} />}
|
||||
title="Real time analytics"
|
||||
content={[
|
||||
'Get instant insights into your data. No need to wait for data to be processed, like other tools out there, looking at you GA4...',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
|
||||
<FeatureContent
|
||||
icon={<DatabaseIcon className="size-8" strokeWidth={1} />}
|
||||
title="Own your own data"
|
||||
content={[
|
||||
'Own your data, no vendor lock-in. Export all your data with our export API.',
|
||||
'Self-host it on your own infrastructure to have complete control.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<CloudIcon className="size-8" strokeWidth={1} />}
|
||||
title="Cloud or self-hosted"
|
||||
content={[
|
||||
'We offer a cloud version of the platform, but you can also self-host it on your own infrastructure.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="/docs/self-hosting/self-hosting"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
More about self-hosting
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-green-500">
|
||||
<FeatureContent
|
||||
icon={<CookieIcon className="size-8" strokeWidth={1} />}
|
||||
title="No cookies"
|
||||
content={[
|
||||
'We care about your privacy, so our tracker does not use cookies. This keeps your data safe and secure.',
|
||||
'We follow GDPR guidelines closely, ensuring your personal information is protected without using invasive technologies.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="/articles/cookieless-analytics"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
Cookieless analytics
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-gray-500">
|
||||
<FeatureContent
|
||||
icon={<GithubIcon className="size-8" strokeWidth={1} />}
|
||||
title="Open-source"
|
||||
content={[
|
||||
'Our code is open and transparent. Contribute, fork, or learn from our implementation.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="https://git.new/openpanel"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
View the code
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
|
||||
<FeatureContent
|
||||
icon={<LockIcon className="size-8" strokeWidth={1} />}
|
||||
title="Your data, your rules"
|
||||
content={[
|
||||
'Complete control over your data. Export, delete, or manage it however you need.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-yellow-500">
|
||||
<FeatureContent
|
||||
icon={<WalletIcon className="size-8" strokeWidth={1} />}
|
||||
title="Affordably priced"
|
||||
content={[
|
||||
'Transparent pricing that scales with your needs. No hidden fees or surprise charges.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-orange-500">
|
||||
<FeatureContent
|
||||
icon={<ZapIcon className="size-8" strokeWidth={1} />}
|
||||
title="Moving fast"
|
||||
content={[
|
||||
'Regular updates and improvements. We move quickly to add features you need.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
|
||||
<FeatureContent
|
||||
icon={<ActivityIcon className="size-8" strokeWidth={1} />}
|
||||
title="Real-time data"
|
||||
content={[
|
||||
'See your analytics as they happen. No waiting for data processing or updates.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<Share2Icon className="size-8" strokeWidth={1} />}
|
||||
title="Sharable reports"
|
||||
content={[
|
||||
'Easily share insights with your team. Export and distribute reports with a single click.',
|
||||
<i key="soon">Coming soon</i>,
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-pink-500">
|
||||
<FeatureContent
|
||||
icon={<BarChart2Icon className="size-8" strokeWidth={1} />}
|
||||
title="Visualize your data"
|
||||
content={[
|
||||
'Beautiful, interactive visualizations that make your data easy to understand.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<LayersIcon className="size-8" strokeWidth={1} />}
|
||||
title="Best of both worlds"
|
||||
content={[
|
||||
'Combine the power of self-hosting with the convenience of cloud deployment.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
</div>
|
||||
|
||||
<Feature media={<EventsFeature />}>
|
||||
<FeatureContent
|
||||
title="Your events"
|
||||
content={[
|
||||
'Track every user interaction with powerful real-time event analytics. See all event properties, user actions, and conversion data in one place.',
|
||||
'From pageviews to custom events, get complete visibility into how users actually use your product.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
cols={1}
|
||||
className="mt-4"
|
||||
title="Some goodies"
|
||||
items={[
|
||||
'• Events arrive within seconds',
|
||||
'• Filter on any property or attribute',
|
||||
'• Get notified on important events',
|
||||
'• Export and analyze event data',
|
||||
'• Track user journeys and conversions',
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
<Feature reverse media={<ProfilesFeature />}>
|
||||
<FeatureContent
|
||||
title="Profiles and sessions"
|
||||
content={[
|
||||
'Get detailed insights into how users interact with your product through comprehensive profile and session tracking. See everything from basic metrics to detailed behavioral patterns.',
|
||||
'Track session duration, page views, and user journeys to understand how people actually use your product.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
cols={1}
|
||||
className="mt-4"
|
||||
title="What can you see?"
|
||||
items={[
|
||||
'• First and last seen dates',
|
||||
'• Session duration and counts',
|
||||
'• Page views and activity patterns',
|
||||
'• User location and device info',
|
||||
'• Browser and OS details',
|
||||
'• Event history and interactions',
|
||||
'• Real-time activity tracking',
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BellIcon,
|
||||
BookOpenIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
HeartIcon,
|
||||
LogOutIcon,
|
||||
MessageSquareIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
Share2Icon,
|
||||
ShoppingCartIcon,
|
||||
StarIcon,
|
||||
ThumbsUpIcon,
|
||||
UserPlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
action: string;
|
||||
location: string;
|
||||
platform: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const locations = [
|
||||
'Gothenburg',
|
||||
'Stockholm',
|
||||
'Oslo',
|
||||
'Copenhagen',
|
||||
'Berlin',
|
||||
'New York',
|
||||
'Singapore',
|
||||
'London',
|
||||
'Paris',
|
||||
'Madrid',
|
||||
'Rome',
|
||||
'Barcelona',
|
||||
'Amsterdam',
|
||||
'Vienna',
|
||||
];
|
||||
const platforms = ['iOS', 'Android', 'Windows', 'macOS'];
|
||||
const browsers = ['WebKit', 'Chrome', 'Firefox', 'Safari'];
|
||||
|
||||
const getCountryFlag = (country: (typeof locations)[number]) => {
|
||||
switch (country) {
|
||||
case 'Gothenburg':
|
||||
return '🇸🇪';
|
||||
case 'Stockholm':
|
||||
return '🇸🇪';
|
||||
case 'Oslo':
|
||||
return '🇳🇴';
|
||||
case 'Copenhagen':
|
||||
return '🇩🇰';
|
||||
case 'Berlin':
|
||||
return '🇩🇪';
|
||||
case 'New York':
|
||||
return '🇺🇸';
|
||||
case 'Singapore':
|
||||
return '🇸🇬';
|
||||
case 'London':
|
||||
return '🇬🇧';
|
||||
case 'Paris':
|
||||
return '🇫🇷';
|
||||
case 'Madrid':
|
||||
return '🇪🇸';
|
||||
case 'Rome':
|
||||
return '🇮🇹';
|
||||
case 'Barcelona':
|
||||
return '🇪🇸';
|
||||
case 'Amsterdam':
|
||||
return '🇳🇱';
|
||||
case 'Vienna':
|
||||
return '🇦🇹';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: (typeof platforms)[number]) => {
|
||||
switch (platform) {
|
||||
case 'iOS':
|
||||
return '🍎';
|
||||
case 'Android':
|
||||
return '🤖';
|
||||
case 'Windows':
|
||||
return '💻';
|
||||
case 'macOS':
|
||||
return '🍎';
|
||||
}
|
||||
};
|
||||
|
||||
const TOTAL_EVENTS = 10;
|
||||
|
||||
export function EventsFeature() {
|
||||
const [events, setEvents] = useState<Event[]>([
|
||||
{
|
||||
id: 1730663803358.4075,
|
||||
action: 'purchase',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: ShoppingCartIcon,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
id: 1730663801358.3079,
|
||||
action: 'logout',
|
||||
location: 'Copenhagen',
|
||||
platform: 'Windows',
|
||||
icon: LogOutIcon,
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
{
|
||||
id: 1730663799358.0283,
|
||||
action: 'sign up',
|
||||
location: 'Berlin',
|
||||
platform: 'Android',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 1730663797357.2036,
|
||||
action: 'share',
|
||||
location: 'Barcelona',
|
||||
platform: 'macOS',
|
||||
icon: Share2Icon,
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
id: 1730663795358.763,
|
||||
action: 'sign up',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 1730663792067.689,
|
||||
action: 'share',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: Share2Icon,
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
id: 1730663790075.3435,
|
||||
action: 'like',
|
||||
location: 'Copenhagen',
|
||||
platform: 'iOS',
|
||||
icon: HeartIcon,
|
||||
color: 'bg-pink-500',
|
||||
},
|
||||
{
|
||||
id: 1730663788070.351,
|
||||
action: 'recommend',
|
||||
location: 'Oslo',
|
||||
platform: 'Android',
|
||||
icon: ThumbsUpIcon,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
id: 1730663786074.429,
|
||||
action: 'read',
|
||||
location: 'New York',
|
||||
platform: 'Windows',
|
||||
icon: BookOpenIcon,
|
||||
color: 'bg-teal-500',
|
||||
},
|
||||
{
|
||||
id: 1730663784065.6309,
|
||||
action: 'sign up',
|
||||
location: 'Gothenburg',
|
||||
platform: 'iOS',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prepend new event every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
setEvents((prevEvents) => [
|
||||
generateEvent(),
|
||||
...prevEvents.slice(0, TOTAL_EVENTS - 1),
|
||||
]);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden p-8 max-h-[700px]">
|
||||
<div
|
||||
className="min-w-[500px] gap-4 flex flex-col overflow-hidden relative isolate"
|
||||
// style={{ height: 60 * TOTAL_EVENTS + 16 * (TOTAL_EVENTS - 1) }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{events.map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
className="flex items-center shadow bg-background-light rounded"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: '60px' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 50,
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-[200px] py-2 px-4">
|
||||
<div
|
||||
className={`size-8 rounded-full bg-background flex items-center justify-center ${event.color} text-white `}
|
||||
>
|
||||
{event.icon && <event.icon size={16} />}
|
||||
</div>
|
||||
<span className="font-medium truncate">{event.action}</span>
|
||||
</div>
|
||||
<div className="w-[150px] py-2 px-4 truncate">
|
||||
<span className="mr-2 text-xl relative top-px">
|
||||
{getCountryFlag(event.location)}
|
||||
</span>
|
||||
{event.location}
|
||||
</div>
|
||||
<div className="w-[150px] py-2 px-4 truncate">
|
||||
<span className="mr-2 text-xl relative top-px">
|
||||
{getPlatformIcon(event.platform)}
|
||||
</span>
|
||||
{event.platform}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to generate events (moved outside component)
|
||||
function generateEvent() {
|
||||
const actions = [
|
||||
{ text: 'sign up', icon: UserPlusIcon, color: 'bg-green-500' },
|
||||
{ text: 'purchase', icon: ShoppingCartIcon, color: 'bg-blue-500' },
|
||||
{ text: 'screen view', icon: EyeIcon, color: 'bg-purple-500' },
|
||||
{ text: 'logout', icon: LogOutIcon, color: 'bg-red-500' },
|
||||
{ text: 'like', icon: HeartIcon, color: 'bg-pink-500' },
|
||||
{ text: 'comment', icon: MessageSquareIcon, color: 'bg-indigo-500' },
|
||||
{ text: 'share', icon: Share2Icon, color: 'bg-cyan-500' },
|
||||
{ text: 'download', icon: DownloadIcon, color: 'bg-emerald-500' },
|
||||
{ text: 'notification', icon: BellIcon, color: 'bg-violet-500' },
|
||||
{ text: 'settings', icon: SettingsIcon, color: 'bg-slate-500' },
|
||||
{ text: 'search', icon: SearchIcon, color: 'bg-violet-500' },
|
||||
{ text: 'read', icon: BookOpenIcon, color: 'bg-teal-500' },
|
||||
{ text: 'recommend', icon: ThumbsUpIcon, color: 'bg-orange-500' },
|
||||
{ text: 'favorite', icon: StarIcon, color: 'bg-yellow-500' },
|
||||
];
|
||||
|
||||
const selectedAction = actions[Math.floor(Math.random() * actions.length)];
|
||||
|
||||
return {
|
||||
id: Date.now() + Math.random(),
|
||||
action: selectedAction.text,
|
||||
location: locations[Math.floor(Math.random() * locations.length)],
|
||||
platform: platforms[Math.floor(Math.random() * platforms.length)],
|
||||
icon: selectedAction.icon,
|
||||
color: selectedAction.color,
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Mock data structure for retention cohort
|
||||
const COHORT_DATA = [
|
||||
{
|
||||
week: 'Week 1',
|
||||
users: '2,543',
|
||||
retention: [100, 84, 73, 67, 62, 58],
|
||||
},
|
||||
{
|
||||
week: 'Week 2',
|
||||
users: '2,148',
|
||||
retention: [100, 80, 69, 63, 59, 55],
|
||||
},
|
||||
{
|
||||
week: 'Week 3',
|
||||
users: '1,958',
|
||||
retention: [100, 82, 71, 64, 60, 56],
|
||||
},
|
||||
{
|
||||
week: 'Week 4',
|
||||
users: '2,034',
|
||||
retention: [100, 83, 72, 65, 61, 57],
|
||||
},
|
||||
{
|
||||
week: 'Week 5',
|
||||
users: '1,987',
|
||||
retention: [100, 81, 70, 64, 60, 56],
|
||||
},
|
||||
{
|
||||
week: 'Week 6',
|
||||
users: '2,245',
|
||||
retention: [100, 85, 74, 68, 64, 60],
|
||||
},
|
||||
{
|
||||
week: 'Week 7',
|
||||
users: '2,108',
|
||||
retention: [100, 82, 71, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 8',
|
||||
users: '1,896',
|
||||
retention: [100, 83, 72, 66],
|
||||
},
|
||||
{
|
||||
week: 'Week 9',
|
||||
users: '2,156',
|
||||
retention: [100, 81, 70],
|
||||
},
|
||||
];
|
||||
const COHORT_DATA_ALT = [
|
||||
{
|
||||
week: 'Week 1',
|
||||
users: '2,876',
|
||||
retention: [100, 79, 76, 70, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 2',
|
||||
users: '2,543',
|
||||
retention: [100, 85, 73, 67, 62, 58],
|
||||
},
|
||||
{
|
||||
week: 'Week 3',
|
||||
users: '2,234',
|
||||
retention: [100, 79, 75, 68, 63, 59],
|
||||
},
|
||||
{
|
||||
week: 'Week 4',
|
||||
users: '2,456',
|
||||
retention: [100, 88, 77, 69, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 5',
|
||||
users: '2,321',
|
||||
retention: [100, 77, 73, 67, 54, 42],
|
||||
},
|
||||
{
|
||||
week: 'Week 6',
|
||||
users: '2,654',
|
||||
retention: [100, 91, 83, 69, 66, 62],
|
||||
},
|
||||
{
|
||||
week: 'Week 7',
|
||||
users: '2,432',
|
||||
retention: [100, 93, 88, 72, 64],
|
||||
},
|
||||
{
|
||||
week: 'Week 8',
|
||||
users: '2,123',
|
||||
retention: [100, 78, 76, 69],
|
||||
},
|
||||
{
|
||||
week: 'Week 9',
|
||||
users: '2,567',
|
||||
retention: [100, 70, 64],
|
||||
},
|
||||
];
|
||||
|
||||
function RetentionCell({ percentage }: { percentage: number }) {
|
||||
// Calculate color intensity based on percentage
|
||||
const getBackgroundColor = (value: number) => {
|
||||
if (value === 0) return 'bg-transparent';
|
||||
// Using CSS color mixing to create a gradient from light to dark blue
|
||||
return `rgb(${Math.round(239 - value * 1.39)} ${Math.round(246 - value * 1.46)} ${Math.round(255 - value * 0.55)})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-px text-sm font-medium w-[80px]">
|
||||
<div
|
||||
className="flex text-white items-center justify-center w-full h-full rounded"
|
||||
style={{
|
||||
backgroundColor: getBackgroundColor(percentage),
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
key={percentage}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{percentage}%
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductAnalyticsFeature() {
|
||||
const [currentData, setCurrentData] = useState(COHORT_DATA);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentData((current) =>
|
||||
current === COHORT_DATA ? COHORT_DATA_ALT : COHORT_DATA,
|
||||
);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* Header row */}
|
||||
<div className="min-w-[70px] flex flex-col">
|
||||
<div className="p-2 font-medium text-xs text-muted-foreground">
|
||||
Cohort
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Week numbers - changed length to 6 */}
|
||||
<div className="flex">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i.toString()}
|
||||
className="text-muted-foreground w-[80px] text-xs text-center p-2 font-medium"
|
||||
>
|
||||
W{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data rows */}
|
||||
<div className="flex flex-col">
|
||||
{currentData.map((cohort, rowIndex) => (
|
||||
<div key={rowIndex.toString()} className="flex">
|
||||
<div className="min-w-[70px] flex flex-col">
|
||||
<div className="p-2 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{cohort.week}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{cohort.retention.map((value, cellIndex) => (
|
||||
<RetentionCell key={cellIndex.toString()} percentage={value} />
|
||||
))}
|
||||
{/* Fill empty cells - changed length to 6 */}
|
||||
{Array.from({ length: 6 - cohort.retention.length }).map(
|
||||
(_, i) => (
|
||||
<div key={`empty-${i.toString()}`} className="w-[80px] p-px">
|
||||
<div className="h-full w-full rounded bg-background" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const PROFILES = [
|
||||
{
|
||||
name: 'Joe Bloggs',
|
||||
email: 'joe@bloggs.com',
|
||||
avatar: '/avatar.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 2 months',
|
||||
lastSeen: '41 minutes',
|
||||
sessions: '8',
|
||||
avgSession: '5m 59s',
|
||||
p90Session: '7m 42s',
|
||||
pageViews: '41',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@smith.com',
|
||||
avatar: '/avatar-2.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 1 month',
|
||||
lastSeen: '2 hours',
|
||||
sessions: '12',
|
||||
avgSession: '4m 32s',
|
||||
p90Session: '6m 15s',
|
||||
pageViews: '35',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Alex Johnson',
|
||||
email: 'alex@johnson.com',
|
||||
avatar: '/avatar-3.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 3 months',
|
||||
lastSeen: '15 minutes',
|
||||
sessions: '15',
|
||||
avgSession: '6m 20s',
|
||||
p90Session: '8m 10s',
|
||||
pageViews: '52',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function ProfilesFeature() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex === PROFILES.length) {
|
||||
setIsTransitioning(false);
|
||||
setCurrentIndex(0);
|
||||
setTimeout(() => setIsTransitioning(true), 50);
|
||||
} else {
|
||||
setCurrentIndex((current) => current + 1);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={`flex ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
|
||||
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{[...PROFILES, PROFILES[0]].map((profile, index) => (
|
||||
<div
|
||||
key={profile.name + index.toString()}
|
||||
className="w-full flex-shrink-0 p-8"
|
||||
>
|
||||
<div className="col md:row justify-center md:justify-start items-center gap-4">
|
||||
<Image
|
||||
src={profile.avatar}
|
||||
className="size-32 rounded-full"
|
||||
width={128}
|
||||
height={128}
|
||||
alt={profile.name}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-3xl font-semibold">{profile.name}</div>
|
||||
<div className="text-muted-foreground text-center md:text-left">
|
||||
{profile.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">First seen</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.firstSeen}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Last seen</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.lastSeen}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Sessions</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.sessions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Avg. Session
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.avgSession}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
P90. Session
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.p90Session}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Page views</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.pageViews}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRICING } from '@openpanel/payments/src/prices';
|
||||
import { CheckIcon, ChevronRightIcon, DollarSignIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { DoubleSwirl } from '../Swirls';
|
||||
import { Section, SectionHeader } from '../section';
|
||||
import { Tag } from '../tag';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
|
||||
export default Pricing;
|
||||
export function Pricing({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn(
|
||||
'overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground xl:rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DoubleSwirl className="absolute top-0 left-0" />
|
||||
<div className="container relative z-10 col">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag variant={'dark'}>
|
||||
<DollarSignIcon className="size-4" />
|
||||
Simple and predictable
|
||||
</Tag>
|
||||
}
|
||||
title="Simple pricing"
|
||||
description="Just pick how many events you want to track each month. No hidden costs."
|
||||
/>
|
||||
|
||||
<div className="grid self-center md:grid-cols-[200px_1fr] lg:grid-cols-[300px_1fr] gap-8">
|
||||
<div className="col gap-4">
|
||||
<h3 className="font-medium text-xl text-background/90 dark:text-foreground/90">
|
||||
Stop overpaying for features
|
||||
</h3>
|
||||
<ul className="gap-1 col text-background/70 dark:text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />
|
||||
Unlimited websites or apps
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited users
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited dashboards
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited charts
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited tracked profiles
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Yes, we have no limits or hidden costs
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
asChild
|
||||
className="self-start mt-4 px-8 group"
|
||||
>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started now
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col justify-between gap-4 max-w-lg">
|
||||
<div className="space-y-2">
|
||||
{PRICING.map((tier) => (
|
||||
<div
|
||||
key={tier.events}
|
||||
className={cn(
|
||||
'group col',
|
||||
'backdrop-blur-3xl bg-foreground/70 dark:bg-background-dark/70',
|
||||
'p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
|
||||
'mx-2',
|
||||
tier.discount &&
|
||||
'mx-0 px-6 py-3 !bg-emerald-900/20 hover:!bg-emerald-900/30',
|
||||
tier.popular &&
|
||||
'mx-0 px-6 py-3 !bg-orange-900/20 hover:!bg-orange-900/30',
|
||||
)}
|
||||
>
|
||||
<div className="row justify-between">
|
||||
<div>
|
||||
{new Intl.NumberFormat('en-US', {}).format(tier.events)}{' '}
|
||||
<span className="text-muted-foreground text-sm max-[420px]:hidden">
|
||||
events / month
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-4">
|
||||
{tier.popular && (
|
||||
<>
|
||||
<Tag variant="dark" className="hidden md:inline-flex">
|
||||
🔥 Popular
|
||||
</Tag>
|
||||
<span className="md:hidden">🔥</span>
|
||||
</>
|
||||
)}
|
||||
{tier.discount && (
|
||||
<>
|
||||
<Tag
|
||||
variant="dark"
|
||||
className="hidden md:inline-flex whitespace-nowrap"
|
||||
>
|
||||
💸 Discount
|
||||
</Tag>
|
||||
<span className="md:hidden">💸</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="row gap-1">
|
||||
{tier.discount && (
|
||||
<span className={cn('text-md font-semibold')}>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(tier.price * (1 - tier.discount.amount))}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-md font-semibold',
|
||||
tier.discount && 'line-through opacity-50',
|
||||
)}
|
||||
>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(tier.price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tier.discount && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Limited discount code available:{' '}
|
||||
<Tooltiper
|
||||
content={`Get ${tier.discount.amount * 100}% off your first year`}
|
||||
delayDuration={0}
|
||||
side="bottom"
|
||||
>
|
||||
<strong>{tier.discount.code}</strong>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'group',
|
||||
'row justify-between p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
|
||||
'mx-2',
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-nowrap">
|
||||
Over{' '}
|
||||
{new Intl.NumberFormat('en-US', {}).format(
|
||||
PRICING[PRICING.length - 1].events,
|
||||
)}
|
||||
</div>
|
||||
<div className="text-md font-semibold">
|
||||
<Link
|
||||
href="mailto:support@openpanel.dev"
|
||||
className="group-hover:underline"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-center text-sm text-muted-foreground mt-4 text-center max-w-[70%] w-full">
|
||||
<strong className="text-background/80 dark:text-foreground/80">
|
||||
All features are included upfront - no hidden costs.
|
||||
</strong>{' '}
|
||||
You choose how many events to track each month.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { type Framework, frameworks } from '@openpanel/sdk-info';
|
||||
import { CodeIcon, ShieldQuestionIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { HorizontalLine, PlusLine, VerticalLine } from '../line';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function Sdks() {
|
||||
return (
|
||||
<Section className="container overflow-hidden">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
|
||||
Easy to use
|
||||
</Tag>
|
||||
}
|
||||
title="SDKs"
|
||||
description="Use our modules to integrate with your favourite framework and start collecting events with ease. Enjoy quick and seamless setup."
|
||||
/>
|
||||
<div className="col gap-16">
|
||||
<div className="relative">
|
||||
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
|
||||
{frameworks.slice(0, 5).map((sdk, index) => (
|
||||
<SdkCard key={sdk.name} sdk={sdk} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<HorizontalLine className="opacity-40 -left-32 -right-32" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
|
||||
{frameworks.slice(5, 10).map((sdk, index) => (
|
||||
<SdkCard key={sdk.name} sdk={sdk} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<HorizontalLine className="opacity-40 -left-32 -right-32" />
|
||||
</div>
|
||||
|
||||
<div className="center-center gap-2 col">
|
||||
<h3 className="text-muted-foreground text-sm">And many more!</h3>
|
||||
<Button asChild>
|
||||
<Link href="/docs">Read our docs</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SdkCard({
|
||||
sdk,
|
||||
index,
|
||||
}: {
|
||||
sdk: Framework;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
key={sdk.name}
|
||||
href={sdk.href}
|
||||
className="group relative z-10 col gap-2 uppercase center-center aspect-video bg-background-light rounded-lg shadow-[inset_0_0_0_1px_theme(colors.border),0_0_30px_0px_hsl(var(--border)/0.5)] transition-all hover:scale-105 hover:bg-background-dark"
|
||||
>
|
||||
{index === 0 && <PlusLine className="opacity-30 top-0 left-0" />}
|
||||
{index === 2 && <PlusLine className="opacity-80 bottom-0 right-0" />}
|
||||
<VerticalLine className="left-0 opacity-40" />
|
||||
<VerticalLine className="right-0 opacity-40" />
|
||||
<div className="absolute inset-0 center-center overflow-hidden opacity-20">
|
||||
<sdk.IconComponent className="size-32 top-[33%] relative group-hover:top-[30%] group-hover:scale-105 transition-all" />
|
||||
</div>
|
||||
<div
|
||||
className="center-center gap-1 col w-full h-full relative rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, hsl(var(--background)) 0%, hsl(var(--background)/0.7) 100%)',
|
||||
}}
|
||||
>
|
||||
<sdk.IconComponent className="size-8" />
|
||||
{/* <h4 className="text-muted-foreground text-[10px]">{sdk.name}</h4> */}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { VerticalLine } from '../line';
|
||||
import { PlusLine } from '../line';
|
||||
import { HorizontalLine } from '../line';
|
||||
import { Section } from '../section';
|
||||
import { Button } from '../ui/button';
|
||||
import { WorldMap } from '../world-map';
|
||||
|
||||
function shortNumber(num: number) {
|
||||
if (num < 1e3) return num;
|
||||
if (num >= 1e3 && num < 1e6) return `${+(num / 1e3).toFixed(1)}K`;
|
||||
if (num >= 1e6 && num < 1e9) return `${+(num / 1e6).toFixed(1)}M`;
|
||||
if (num >= 1e9 && num < 1e12) return `${+(num / 1e9).toFixed(1)}B`;
|
||||
if (num >= 1e12) return `${+(num / 1e12).toFixed(1)}T`;
|
||||
}
|
||||
|
||||
export async function Stats() {
|
||||
const { projectsCount, eventsCount, eventsLast24hCount } = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/misc/stats`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.catch(() => ({
|
||||
projectsCount: 0,
|
||||
eventsCount: 0,
|
||||
eventsLast24hCount: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<StatsPure
|
||||
projectCount={projectsCount}
|
||||
eventCount={eventsCount}
|
||||
last24hCount={eventsLast24hCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPure({
|
||||
projectCount,
|
||||
eventCount,
|
||||
last24hCount,
|
||||
}: { projectCount: number; eventCount: number; last24hCount: number }) {
|
||||
return (
|
||||
<Section className="bg-gradient-to-b from-background via-background-dark to-background-dark py-64 pt-44 relative overflow-hidden -mt-16">
|
||||
{/* Map */}
|
||||
<div className="absolute inset-0 -top-20 center-center items-start select-none opacity-10">
|
||||
<div className="min-w-[1400px] w-full">
|
||||
<WorldMap />
|
||||
{/* Gradient over Map */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<HorizontalLine />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 container center-center">
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<PlusLine className="hidden lg:block top-0 left-0" />
|
||||
<div className="text-muted-foreground text-xs">Active projects</div>
|
||||
<div className="text-5xl font-bold font-mono">{projectCount}</div>
|
||||
</div>
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<div className="text-muted-foreground text-xs">Total events</div>
|
||||
<div className="text-5xl font-bold font-mono">
|
||||
{shortNumber(eventCount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<VerticalLine className="hidden lg:block right-0" />
|
||||
<PlusLine className="hidden lg:block bottom-0 left-0" />
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Events last 24 h
|
||||
</div>
|
||||
<div className="text-5xl font-bold font-mono">
|
||||
{shortNumber(last24hCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalLine />
|
||||
</div>
|
||||
<div className="center-center col gap-4 absolute bottom-20 left-0 right-0 z-10">
|
||||
<p>Get the analytics you deserve</p>
|
||||
<Button asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { TwitterCard } from '@/components/twitter-card';
|
||||
import { MessageCircleIcon } from 'lucide-react';
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-steven.jpg',
|
||||
name: 'Steven Tey',
|
||||
handle: 'steventey',
|
||||
content: [
|
||||
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
|
||||
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
|
||||
'Built by @CarlLindesvard and it’s already tracking 750K+ events 🤩',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-pontus.jpg',
|
||||
name: 'Pontus Abrahamsson — oss/acc',
|
||||
handle: 'pontusab',
|
||||
content: ['Thanks, OpenPanel is a beast, love it!'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-piotr.jpg',
|
||||
name: 'Piotr Kulpinski',
|
||||
handle: 'piotrkulpinski',
|
||||
content: [
|
||||
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
|
||||
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-greg.png',
|
||||
name: 'greg hodson 🍜',
|
||||
handle: 'h0dson',
|
||||
content: ['i second this, openpanel is killing it'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-jacob.jpg',
|
||||
name: 'Jacob 🍀 Build in Public',
|
||||
handle: 'javayhuwx',
|
||||
content: [
|
||||
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
|
||||
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
|
||||
'#buildinpublic #indiehackers',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-lee.jpg',
|
||||
name: 'Lee',
|
||||
handle: 'DutchEngIishman',
|
||||
content: [
|
||||
'Day two of marketing.',
|
||||
'I like this upward trend..',
|
||||
'P.S. website went live on Sunday',
|
||||
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
];
|
||||
|
||||
export default Testimonials;
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<MessageCircleIcon className="size-4" strokeWidth={1.5} />
|
||||
Testimonials
|
||||
</Tag>
|
||||
}
|
||||
title="What people say"
|
||||
description="What our customers say about us."
|
||||
/>
|
||||
<div className="col md:row gap-4">
|
||||
<div className="col gap-4 flex-1">
|
||||
{testimonials.slice(0, testimonials.length / 2).map((testimonial) => (
|
||||
<TwitterCard key={testimonial.handle} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
<div className="col gap-4 flex-1">
|
||||
{testimonials.slice(testimonials.length / 2).map((testimonial) => (
|
||||
<TwitterCard key={testimonial.handle} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
##### Web options
|
||||
|
||||
- `trackScreenViews` - If true, the library will automatically track screen views (default: false)
|
||||
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
|
||||
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Section, SectionHeader } from './section';
|
||||
import { Tag } from './tag';
|
||||
import { Tooltip } from './ui/tooltip';
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: 'Helpy UI',
|
||||
url: 'https://helpy-ui.com',
|
||||
logo: 'helpy-ui.png',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'KiddoKitchen',
|
||||
url: 'https://kiddokitchen.se',
|
||||
logo: 'kiddokitchen.png',
|
||||
border: false,
|
||||
},
|
||||
{
|
||||
name: 'Maneken',
|
||||
url: 'https://maneken.app',
|
||||
logo: 'maneken.jpg',
|
||||
border: false,
|
||||
},
|
||||
{
|
||||
name: 'Midday',
|
||||
url: 'https://midday.ai',
|
||||
logo: 'midday.png',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'Screenzen',
|
||||
url: 'https://www.screenzen.co',
|
||||
logo: 'screenzen.avif',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'Tiptip',
|
||||
url: 'https://tiptip.id',
|
||||
logo: 'tiptip.jpg',
|
||||
border: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyOpenPanel() {
|
||||
return (
|
||||
<div className="bg-background-light my-12 col">
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
title="Why OpenPanel?"
|
||||
description="We built OpenPanel to get the best of both web and product analytics. With that in mind we have created a simple but very powerful platform that can handle most companies needs."
|
||||
/>
|
||||
<div className="center-center col gap-4 -mt-4">
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
With 2000+ registered projects
|
||||
</Tag>
|
||||
<div className="row gap-4 justify-center flex-wrap">
|
||||
{images.map((image) => (
|
||||
<a
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
key={image.logo}
|
||||
className={cn(
|
||||
'group rounded-lg bg-white center-center size-20 hover:scale-110 transition-all duration-300',
|
||||
image.border && 'p-2 border border-border shadow-sm',
|
||||
)}
|
||||
title={image.name}
|
||||
>
|
||||
<Image
|
||||
src={`/logos/${image.logo}`}
|
||||
alt={image.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg grayscale group-hover:grayscale-0 transition-all duration-300"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
import DottedMap from 'dotted-map/without-countries';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { mapJsonString } from './world-map-string';
|
||||
|
||||
// Static coordinates list with 50 points
|
||||
const COORDINATES = [
|
||||
// Western Hemisphere (Focused on West Coast)
|
||||
{ lat: 47.6062, lng: -122.3321 }, // Seattle, USA
|
||||
{ lat: 45.5155, lng: -122.6789 }, // Portland, USA
|
||||
{ lat: 37.7749, lng: -122.4194 }, // San Francisco, USA
|
||||
{ lat: 34.0522, lng: -118.2437 }, // Los Angeles, USA
|
||||
{ lat: 32.7157, lng: -117.1611 }, // San Diego, USA
|
||||
{ lat: 49.2827, lng: -123.1207 }, // Vancouver, Canada
|
||||
{ lat: 58.3019, lng: -134.4197 }, // Juneau, Alaska
|
||||
{ lat: 61.2181, lng: -149.9003 }, // Anchorage, Alaska
|
||||
{ lat: 64.8378, lng: -147.7164 }, // Fairbanks, Alaska
|
||||
{ lat: 71.2906, lng: -156.7886 }, // Utqiaġvik (Barrow), Alaska
|
||||
{ lat: 60.5544, lng: -151.2583 }, // Kenai, Alaska
|
||||
{ lat: 61.5815, lng: -149.444 }, // Wasilla, Alaska
|
||||
{ lat: 66.1666, lng: -153.3707 }, // Bettles, Alaska
|
||||
{ lat: 63.8659, lng: -145.637 }, // Delta Junction, Alaska
|
||||
{ lat: 55.3422, lng: -131.6461 }, // Ketchikan, Alaska
|
||||
|
||||
// Eastern Hemisphere (Focused on East Asia)
|
||||
{ lat: 35.6762, lng: 139.6503 }, // Tokyo, Japan
|
||||
{ lat: 43.0621, lng: 141.3544 }, // Sapporo, Japan
|
||||
{ lat: 26.2286, lng: 127.6809 }, // Naha, Japan
|
||||
{ lat: 31.2304, lng: 121.4737 }, // Shanghai, China
|
||||
{ lat: 22.3193, lng: 114.1694 }, // Hong Kong
|
||||
{ lat: 37.5665, lng: 126.978 }, // Seoul, South Korea
|
||||
{ lat: 25.033, lng: 121.5654 }, // Taipei, Taiwan
|
||||
|
||||
// Russian Far East
|
||||
{ lat: 64.7336, lng: 177.5169 }, // Anadyr, Russia
|
||||
{ lat: 59.5613, lng: 150.8086 }, // Magadan, Russia
|
||||
{ lat: 43.1332, lng: 131.9113 }, // Vladivostok, Russia
|
||||
{ lat: 53.0444, lng: 158.6478 }, // Petropavlovsk-Kamchatsky, Russia
|
||||
{ lat: 62.0355, lng: 129.6755 }, // Yakutsk, Russia
|
||||
{ lat: 48.4827, lng: 135.0846 }, // Khabarovsk, Russia
|
||||
{ lat: 46.9589, lng: 142.7319 }, // Yuzhno-Sakhalinsk, Russia
|
||||
{ lat: 52.9651, lng: 158.2728 }, // Yelizovo, Russia
|
||||
{ lat: 56.1304, lng: 101.614 }, // Bratsk, Russia
|
||||
|
||||
// Australia & New Zealand (Main Cities)
|
||||
{ lat: -33.8688, lng: 151.2093 }, // Sydney, Australia
|
||||
{ lat: -37.8136, lng: 144.9631 }, // Melbourne, Australia
|
||||
{ lat: -27.4698, lng: 153.0251 }, // Brisbane, Australia
|
||||
{ lat: -31.9505, lng: 115.8605 }, // Perth, Australia
|
||||
{ lat: -12.4634, lng: 130.8456 }, // Darwin, Australia
|
||||
{ lat: -34.9285, lng: 138.6007 }, // Adelaide, Australia
|
||||
{ lat: -42.8821, lng: 147.3272 }, // Hobart, Australia
|
||||
{ lat: -16.9186, lng: 145.7781 }, // Cairns, Australia
|
||||
{ lat: -23.7041, lng: 133.8814 }, // Alice Springs, Australia
|
||||
{ lat: -41.2865, lng: 174.7762 }, // Wellington, New Zealand
|
||||
{ lat: -36.8485, lng: 174.7633 }, // Auckland, New Zealand
|
||||
{ lat: -43.532, lng: 172.6306 }, // Christchurch, New Zealand
|
||||
];
|
||||
|
||||
const getRandomCoordinates = (count: number) => {
|
||||
const shuffled = [...COORDINATES].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
};
|
||||
|
||||
export function WorldMap() {
|
||||
const [visiblePins, setVisiblePins] = useState<typeof COORDINATES>([
|
||||
{ lat: 61.2181, lng: -149.9003 },
|
||||
{ lat: 31.2304, lng: 121.4737 },
|
||||
{ lat: 59.5613, lng: 150.8086 },
|
||||
{ lat: 64.8378, lng: -147.7164 },
|
||||
{ lat: -33.8688, lng: 151.2093 },
|
||||
{ lat: 43.0621, lng: 141.3544 },
|
||||
{ lat: 58.3019, lng: -134.4197 },
|
||||
{ lat: 37.5665, lng: 126.978 },
|
||||
{ lat: -41.2865, lng: 174.7762 },
|
||||
{ lat: -36.8485, lng: 174.7633 },
|
||||
{ lat: -31.9505, lng: 115.8605 },
|
||||
{ lat: 35.6762, lng: 139.6503 },
|
||||
{ lat: 49.2827, lng: -123.1207 },
|
||||
{ lat: -12.4634, lng: 130.8456 },
|
||||
{ lat: 56.1304, lng: 101.614 },
|
||||
{ lat: 22.3193, lng: 114.1694 },
|
||||
{ lat: 55.3422, lng: -131.6461 },
|
||||
{ lat: 32.7157, lng: -117.1611 },
|
||||
{ lat: 61.5815, lng: -149.444 },
|
||||
{ lat: 60.5544, lng: -151.2583 },
|
||||
]);
|
||||
const activePinColor = '#2265EC';
|
||||
const inactivePinColor = '#818181';
|
||||
const visiblePinsCount = 20;
|
||||
|
||||
// Helper function to update pins
|
||||
const updatePins = () => {
|
||||
setVisiblePins((current) => {
|
||||
const newPins = [...current];
|
||||
// Remove 2 random pins
|
||||
const pinsToAdd = 4;
|
||||
if (newPins.length >= pinsToAdd) {
|
||||
for (let i = 0; i < pinsToAdd; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * newPins.length);
|
||||
newPins.splice(randomIndex, 1);
|
||||
}
|
||||
}
|
||||
// Add 2 new random pins from the main coordinates
|
||||
const availablePins = COORDINATES.filter(
|
||||
(coord) =>
|
||||
!newPins.some(
|
||||
(pin) => pin.lat === coord.lat && pin.lng === coord.lng,
|
||||
),
|
||||
);
|
||||
const newRandomPins = availablePins
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, pinsToAdd);
|
||||
return [...newPins, ...newRandomPins].slice(0, visiblePinsCount);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update pins every 4 seconds
|
||||
const interval = setInterval(updatePins, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const map = useMemo(() => {
|
||||
const map = new DottedMap({ map: mapJsonString as any });
|
||||
|
||||
visiblePins.forEach((coord) => {
|
||||
map.addPin({
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
svgOptions: { color: activePinColor, radius: 0.3 },
|
||||
});
|
||||
});
|
||||
|
||||
return map.getSVG({
|
||||
radius: 0.2,
|
||||
color: inactivePinColor,
|
||||
shape: 'circle',
|
||||
});
|
||||
}, [visiblePins]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img
|
||||
loading="lazy"
|
||||
alt="World map with active users"
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(map)}`}
|
||||
className="object-contain w-full h-full"
|
||||
width={1200}
|
||||
height={630}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
---
|
||||
title: Find an alternative to Mixpanel
|
||||
description: A list of alternatives to Mixpanel, including open source and paid options.
|
||||
date: 2024-11-12
|
||||
team: OpenPanel Team
|
||||
tag: Comparison
|
||||
cover: /content/cover-alternatives.jpg
|
||||
---
|
||||
|
||||
> Want to understand how people use your website? You might think of using Mixpanel first. But it can be complex and hard to learn.
|
||||
|
||||
Think about using something else that's just as good but simpler to use. A tool that makes collecting data easy without the struggle of learning complex features.
|
||||
|
||||
Here's what a better website analytics tool can give you:
|
||||
- **Confidence**: Make choices based on data
|
||||
- **Efficiency**: Work faster with your analytics
|
||||
- **Ease**: Less complex, easier to learn
|
||||
|
||||
## Understanding Website Analytics
|
||||
|
||||
Website analytics helps you collect and understand website data. It shows you how people use your website.
|
||||
|
||||
Since 2016, more companies have started using digital analytics. Companies now want to know how users behave, spot patterns, and make better choices using data.
|
||||
|
||||
Just counting website visits isn't enough anymore. Understanding how users interact with your website can show you important insights and opportunities.
|
||||
|
||||
Good website analytics helps you set goals, track progress, and make your website better. You need tools that are both powerful and easy to use.
|
||||
|
||||
These tools turn raw numbers into useful insights, helping you stay ahead of others.
|
||||
|
||||
## Introduction to Mixpanel
|
||||
|
||||
Mixpanel is a powerful analytics tool that helps marketers, developers, and product managers understand how users behave on their websites and apps.
|
||||
|
||||
Started in 2009, this platform changed how we look at data in real-time.
|
||||
|
||||
Mixpanel helps teams track user engagement and keep users coming back.
|
||||
|
||||
It tracks specific user actions instead of just page views, giving you better insights into what users do.
|
||||
|
||||
Its dashboard shows real-time data clearly, helping teams make better decisions.
|
||||
|
||||
Mixpanel remains a strong player in analytics, helping businesses improve their online presence.
|
||||
|
||||
## Limitations of Mixpanel
|
||||
|
||||
Despite its strengths, Mixpanel has several problems users need to deal with.
|
||||
|
||||
First, Mixpanel's pricing is often too high. The cost of all its features may not make sense for smaller companies or startups, making it hard for growing businesses to use. Simply put, you might not get enough value for what you pay.
|
||||
|
||||
Second, Mixpanel is hard to learn. New users often struggle with its complex interface.
|
||||
|
||||
Third, Mixpanel doesn't work well with some important business tools. This makes it hard to connect all your data in one place.
|
||||
|
||||
Lastly, setting up event tracking is difficult. Users need to carefully set up tracking for each action they want to monitor, which takes time and can lead to mistakes. This means teams often spend too much time setting things up instead of using the data right away.
|
||||
|
||||
## The Need for Simpler Solutions
|
||||
|
||||
In today's busy market, having easy-to-use analytics is key for business success.
|
||||
|
||||
Simple tools help companies collect and understand data without confusion.
|
||||
|
||||
These tools are easy to use and quick to set up, saving time and money. By making data collection and analysis simpler, businesses of any size can use analytics without needing technical experts or long training.
|
||||
|
||||
More importantly, simple solutions help small and medium-sized businesses compete better. Good data insights can change how well a business does. With easy-to-use alternatives to Mixpanel, even businesses with small budgets can grow and make smart choices. Using these simple tools lets businesses focus on what matters—growing and succeeding.
|
||||
|
||||
## Key Features to Look For
|
||||
|
||||
When choosing a Mixpanel alternative, look for these important features:
|
||||
- Easy to set up
|
||||
- Real-time data
|
||||
- Simple to use
|
||||
- Good data charts and graphs
|
||||
|
||||
First, it should be easy to connect with your website.
|
||||
|
||||
The tool should show you data as it happens, helping you make quick decisions.
|
||||
|
||||
It should be easy to use. A tool that's simple to understand saves time and is easier to learn.
|
||||
|
||||
Good data charts are important. Look for tools that show data in ways that make sense to you.
|
||||
|
||||
You should be able to change the tool to fit your needs.
|
||||
|
||||
Lastly, it should be worth the money. The best tool gives you good features at a fair price.
|
||||
|
||||
## Benefits of a Mixpanel Alternative
|
||||
|
||||
Using a simpler alternative to Mixpanel can make your work easier and better.
|
||||
|
||||
First, it's more efficient. Simpler tools are faster to learn and use, helping your team work better. When tools are easier to use, people enjoy using them more.
|
||||
|
||||
Also, you can save money. Many alternatives do similar things as Mixpanel but cost less, letting you spend money on other important things while still getting good analytics.
|
||||
|
||||
Finally, alternatives often let you customize more things to fit your needs. This helps you get better insights and make better plans.
|
||||
|
||||
## Cost-Effectiveness
|
||||
|
||||
> Choosing a simpler Mixpanel alternative can save you money and help you grow.
|
||||
|
||||
The lower prices of many alternatives help you save money. You can use this saved money for other important things while still getting good analytics. These savings add up over time.
|
||||
|
||||
**Key Benefits:**
|
||||
- Lower prices
|
||||
- Fewer extra features you don't need
|
||||
- Less training needed
|
||||
- Faster to start using
|
||||
|
||||
In the end, saving money with alternatives isn't just about the price. By using simpler tools, businesses can balance cost and features better.
|
||||
|
||||
## User-Friendly Interface
|
||||
|
||||
> A simple, easy-to-use design means you can start using the data quickly, without lots of training.
|
||||
|
||||
A big benefit of Mixpanel alternatives is how easy they are to use. This helps everyone use the tool well, no matter their tech skills.
|
||||
|
||||
Simple navigation helps you work faster. Users can find what they need quickly and easily. By focusing on the main features, users don't get confused by too many options.
|
||||
|
||||
**Impact:** When tools are easy to use, teams can do better work without getting frustrated. Clear dashboards show important information simply.
|
||||
|
||||
## Customizable Reports
|
||||
|
||||
> Your data, your way. Turn numbers into insights that help you take action.
|
||||
|
||||
Custom reports let you see data how you want to. This saves time and helps you understand complex data better.
|
||||
|
||||
A good Mixpanel alternative should have:
|
||||
- Easy drag-and-drop tools
|
||||
- Live data updates
|
||||
- Different ways to show charts
|
||||
|
||||
## Data Accuracy
|
||||
|
||||
> Accurate data helps you trust your analytics.
|
||||
|
||||
In website analytics, good data is very important. It affects your decisions and results. With a Mixpanel alternative, getting accurate data is key.
|
||||
|
||||
**Important Points:**
|
||||
- Data you can trust
|
||||
- Regular accuracy checks
|
||||
- Ongoing data testing
|
||||
- Building trust with your team
|
||||
|
||||
## Real-Time Analytics
|
||||
|
||||
> In today's fast-moving online world, seeing data right away helps you make better decisions.
|
||||
|
||||
These tools let you watch how people use your website as it happens. You don't have to wait for reports; you see everything right away.
|
||||
|
||||
**Impact:** Whether you're tracking clicks, page views, or sales, seeing data right away helps you fix problems quickly and find new opportunities.
|
||||
|
||||
## Tips for Transitioning
|
||||
|
||||
> A good switch starts with a clear plan and ends with confident users.
|
||||
|
||||
Start by making a clear plan with goals and timelines. This helps everyone understand what's happening.
|
||||
|
||||
**Best Steps:**
|
||||
1. Get your team involved early
|
||||
2. Train everyone well
|
||||
3. Test with a small project first
|
||||
4. Keep talking with your team
|
||||
5. Find team members who can help others
|
||||
|
||||
## Future Trends in Website Analytics
|
||||
|
||||
> As websites change, analytics tools are changing too, bringing new ways to understand data.
|
||||
|
||||
**Important New Trends:**
|
||||
|
||||
**AI tools**
|
||||
AI helps businesses not just understand what users did before, but guess what they might do next.
|
||||
|
||||
**Privacy first analytics**
|
||||
With new privacy laws like GDPR and CCPA, companies are finding new ways to get insights while protecting user privacy.
|
||||
|
||||
**Quick data updates**
|
||||
Getting data quickly helps businesses make faster, better decisions.
|
||||
|
||||
By using these new tools, businesses can better understand their users and do better online.
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: Better compliance with self-hosted analytics
|
||||
description: A practical guide to GDPR, CCPA, HIPAA, and other privacy regulations for analytics. Learn how OpenPanel and self-hosting can simplify compliance.
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-12-08
|
||||
updated: 2026-02-07
|
||||
cover: /content/compliance.jpg
|
||||
---
|
||||
|
||||
Privacy regulations are everywhere now. GDPR in Europe, CCPA in California, HIPAA for healthcare, and the list keeps growing. If you're running a website or app, you've probably wondered: "Am I actually compliant with all this stuff?"
|
||||
|
||||
The good news? Analytics compliance doesn't have to be complicated or expensive. The bad news? Most traditional analytics tools make it way harder than it needs to be.
|
||||
|
||||
In this guide, we'll break down the major compliance frameworks, explain what they actually mean for your analytics setup, and show you how [OpenPanel](/) can help you stay compliant without the headache.
|
||||
|
||||
## Why Analytics Compliance Matters
|
||||
|
||||
Let's start with the basics. When someone visits your website, you're collecting data about them. Maybe it's their location, what pages they viewed, how long they stayed, or what buttons they clicked. Under most privacy laws, this counts as personal data.
|
||||
|
||||
The consequences of getting compliance wrong are real. GDPR fines can reach €20 million or 4% of global revenue, whichever is higher. CCPA violations cost up to $7,988 per intentional violation. And beyond the fines, there's the reputation damage and loss of customer trust.
|
||||
|
||||
Here's the thing though: most compliance issues with analytics come down to a few core problems.
|
||||
|
||||
**Third-party data sharing.** When you use Google Analytics or similar tools, your visitors' data flows through their servers. That creates a chain of custody problem. You're responsible for what happens to that data, even when it's sitting on someone else's infrastructure.
|
||||
|
||||
**Cookies and consent.** Traditional analytics tools rely heavily on cookies. Under GDPR, PECR, and similar regulations, you need explicit consent before dropping most cookies. That means cookie banners, consent management, and all the friction that comes with it.
|
||||
|
||||
**International data transfers.** If you're collecting data from EU residents and it ends up on US servers, you've got a potential compliance issue. This is exactly why Google Analytics has been ruled illegal in several EU countries.
|
||||
|
||||
The solution? Either use a [privacy-first analytics tool](/articles/cookieless-analytics) that sidesteps these issues, or self-host your analytics so data never leaves your infrastructure.
|
||||
|
||||
## GDPR: The One Everyone Knows About
|
||||
|
||||
The General Data Protection Regulation is the big one. It applies to any organization that processes personal data of EU residents, regardless of where that organization is based. So if you have visitors from Europe, GDPR applies to you.
|
||||
|
||||
### What GDPR Requires for Analytics
|
||||
|
||||
GDPR is built around a few key principles that directly impact how you can do analytics.
|
||||
|
||||
**Lawful basis for processing.** You need a legal reason to collect and process personal data. For analytics, this usually means either getting consent or demonstrating "legitimate interest." Consent is cleaner but requires those annoying cookie banners. Legitimate interest is possible but requires documentation and balancing tests.
|
||||
|
||||
**Data minimization.** Only collect what you actually need. If you're tracking 50 different user properties but only looking at 5 of them, you've got a problem.
|
||||
|
||||
**Right to erasure.** Users can request that you delete their data. You need to be able to actually do this, which is tricky when your data is sitting in a third-party's database.
|
||||
|
||||
**Transparency.** Users need to know what you're collecting and why. This means clear privacy policies and, in most cases, cookie consent interfaces.
|
||||
|
||||
### Why Google Analytics Keeps Getting Banned
|
||||
|
||||
Google Analytics has been declared non-compliant with GDPR by data protection authorities in Austria, France, Italy, and other EU countries. The core issue is that GA transfers personal data (including IP addresses) to US servers, where it can potentially be accessed by US intelligence agencies. This violates Chapter V of GDPR, which governs international data transfers.
|
||||
|
||||
Even with IP anonymization enabled, the data still hits Google's servers before being anonymized. That's a problem.
|
||||
|
||||
<WindowImage
|
||||
srcDark="/screenshots/overview-dark.webp"
|
||||
srcLight="/screenshots/overview-light.webp"
|
||||
alt="OpenPanel Dashboard Overview"
|
||||
caption="This is how OpenPanel dashboard looks like, the self-hosting version has all features that our cloud version has. The release lifecycle is 2-3 months behind cloud version."
|
||||
/>
|
||||
|
||||
### How OpenPanel Handles GDPR
|
||||
|
||||
OpenPanel takes a different approach. We built it with privacy as the foundation, not an afterthought.
|
||||
|
||||
**Cookieless by default.** OpenPanel doesn't use cookies for tracking. No cookies means no cookie consent banners required for basic analytics. Your visitors get a cleaner experience, and you avoid the consent management complexity. Learn more about how this works in our [cookieless analytics guide](/articles/cookieless-analytics).
|
||||
|
||||
**No third-party data sharing.** With OpenPanel Cloud, your data stays in our EU-based infrastructure. With [self-hosting](/articles/how-to-self-host-openpanel), data never leaves your servers at all.
|
||||
|
||||
**Built-in data export and deletion.** Need to handle a data subject request? OpenPanel's [Export API](/docs/api/export) makes it straightforward to export user data. You can delete your entire project's data through the dashboard, and if you need to delete a specific identified profile, you can request that from us.
|
||||
|
||||
**Transparent and open source.** You can [audit the code yourself](https://github.com/Openpanel-dev/openpanel) to see exactly what's being collected and how it's processed.
|
||||
|
||||
## CCPA: California's Privacy Law
|
||||
|
||||
The California Consumer Privacy Act (and its amendment, CPRA) gives California residents specific rights over their personal information. If you do business in California or collect data from California residents, this one matters.
|
||||
|
||||
### Key CCPA Requirements
|
||||
|
||||
**Right to know.** Consumers can ask what personal information you've collected about them, where it came from, and who you've shared it with.
|
||||
|
||||
**Right to delete.** Similar to GDPR, consumers can request deletion of their personal information.
|
||||
|
||||
**Right to opt-out.** Here's the big one for analytics. Consumers can opt out of the "sale" or "sharing" of their personal information. And under CCPA, "sharing" includes providing data to third parties for cross-context behavioral advertising, which is exactly what many analytics tools do.
|
||||
|
||||
**No discrimination.** You can't treat consumers differently because they exercised their privacy rights.
|
||||
|
||||
### The "Do Not Sell" Problem
|
||||
|
||||
Many traditional analytics tools technically "share" your user data with third parties. When you use Google Analytics, user data flows through Google's systems and can be used for their own purposes. Under CCPA, this could be considered sharing, which means you need to honor "Do Not Sell or Share" requests.
|
||||
|
||||
This creates a real operational burden. You need systems to track opt-out requests, communicate them to all your vendors, and verify compliance.
|
||||
|
||||
### How OpenPanel Simplifies CCPA
|
||||
|
||||
With OpenPanel, there's no sharing to opt out of.
|
||||
|
||||
When you use OpenPanel Cloud, your data is processed solely for your analytics purposes. We don't sell or share your data with anyone. When you [self-host OpenPanel](/docs/self-hosting/self-hosting), you control the entire data pipeline. There's no third party involved at all.
|
||||
|
||||
This architectural difference eliminates most CCPA complexity. You still need proper privacy disclosures, but you don't need to worry about vendor management for your analytics data.
|
||||
|
||||
## HIPAA: Healthcare's Special Rules
|
||||
|
||||
If you're in healthcare or handle Protected Health Information (PHI), HIPAA adds another layer of compliance requirements. This is where things get expensive with traditional analytics providers.
|
||||
|
||||
### The BAA Requirement
|
||||
|
||||
HIPAA requires that any third party with access to PHI must sign a Business Associate Agreement (BAA). This is a legal contract that establishes what the vendor can and can't do with health information.
|
||||
|
||||
The problem? Most analytics providers either don't offer BAAs at all, or charge significant premiums for them. We're talking enterprise-tier pricing that can run into tens of thousands of dollars annually.
|
||||
|
||||
Google Analytics doesn't offer a BAA. Mixpanel does, but only on enterprise plans. The same goes for most major analytics platforms.
|
||||
|
||||
### What Counts as PHI in Analytics
|
||||
|
||||
This is where many healthcare organizations get tripped up. PHI isn't just medical records. Under HHS guidance, when someone visits a healthcare website's authenticated pages, their IP address combined with the fact that they're viewing health-related content can constitute PHI.
|
||||
|
||||
This means that if you're using cookie-based tracking on a patient portal or healthcare app, you might be sharing PHI with your analytics provider without realizing it.
|
||||
|
||||
### The Self-Hosting Solution
|
||||
|
||||
Here's where self-hosting completely changes the equation: if you host your own analytics, you don't need a BAA.
|
||||
|
||||
Think about it. A BAA is required when you're sharing PHI with a business associate. But if you [self-host OpenPanel](/articles/how-to-self-host-openpanel) on your own HIPAA-compliant infrastructure, there's no third party involved. The data never leaves your environment. There's no business associate relationship to manage.
|
||||
|
||||
This approach lets you get meaningful analytics from your healthcare applications without the enterprise pricing or legal complexity. You deploy OpenPanel on your existing HIPAA-compliant servers using [Docker Compose](/docs/self-hosting/deploy-docker-compose), [Kubernetes](/docs/self-hosting/deploy-kubernetes), or your preferred deployment method, and you're done.
|
||||
|
||||
## PECR: The UK's Cookie Law
|
||||
|
||||
If you have visitors from the UK, you need to think about PECR (Privacy and Electronic Communications Regulations) alongside UK GDPR. PECR specifically regulates cookies and similar tracking technologies.
|
||||
|
||||
### What PECR Requires
|
||||
|
||||
PECR has a simple but strict rule: you need consent before storing or accessing information on a user's device. This includes cookies, local storage, and similar technologies.
|
||||
|
||||
There are only two exemptions. The "communication exemption" covers technologies essential for transmitting a communication. The "strictly necessary exemption" covers technologies essential for providing a service the user explicitly requested.
|
||||
|
||||
Here's the important part: **analytics cookies are not exempt.** The UK's Information Commissioner's Office has been clear about this. If you're using cookie-based analytics, you need consent.
|
||||
|
||||
### Fines Are Increasing
|
||||
|
||||
PECR fines used to be capped at £500,000. The new Data (Use and Access) Act aligns PECR penalties with UK GDPR, meaning potential fines of up to £17.5 million. The ICO has also been increasingly active in enforcing cookie compliance.
|
||||
|
||||
### Cookieless Analytics Bypasses PECR
|
||||
|
||||
Since [OpenPanel's tracking is cookieless](/articles/cookieless-analytics), the PECR consent requirement simply doesn't apply to basic analytics. You're not storing anything on the user's device, so there's nothing to consent to.
|
||||
|
||||
This doesn't mean you can track whatever you want. UK GDPR still applies to the processing of personal data. But it does mean you can skip the cookie banners and consent management platforms that PECR would otherwise require.
|
||||
|
||||
## The Self-Hosting Advantage
|
||||
|
||||
We've mentioned self-hosting several times now, and for good reason. It's the single most effective way to simplify analytics compliance across almost every framework.
|
||||
|
||||
### What Self-Hosting Actually Means
|
||||
|
||||
When you self-host OpenPanel, you run the entire analytics platform on your own infrastructure. This could be your own servers, your cloud account (AWS, GCP, Azure, etc.), or even a simple VPS.
|
||||
|
||||
The data flow is completely different from traditional analytics.
|
||||
|
||||
**Traditional analytics:** User → Your website → Analytics provider's servers → Provider dashboard
|
||||
|
||||
**Self-hosted analytics:** User → Your website → Your servers → Your dashboard
|
||||
|
||||
That middle step makes all the difference. With traditional analytics, you're sharing data with a third party. With self-hosting, data never leaves your control.
|
||||
|
||||
### Compliance Benefits Across Frameworks
|
||||
|
||||
**GDPR:** No international data transfers if you host in the EU. Full control over data retention and deletion. No third-party data sharing to manage.
|
||||
|
||||
**CCPA:** No "selling" or "sharing" by definition. You're not providing data to any third party.
|
||||
|
||||
**HIPAA:** No BAA required because there's no business associate. PHI stays within your HIPAA-compliant environment.
|
||||
|
||||
**PECR:** Cookieless tracking means no consent requirements for basic analytics.
|
||||
|
||||
**SOC 2:** Easier vendor risk management when you control the analytics infrastructure. Your existing security controls apply.
|
||||
|
||||
### Beyond Compliance
|
||||
|
||||
Self-hosting isn't just about compliance. There are real practical benefits too.
|
||||
|
||||
**Cost predictability.** No per-event pricing surprises. Your costs are your server costs, which are typically much lower than SaaS analytics pricing at scale.
|
||||
|
||||
**No vendor lock-in.** Your data is in your database. You can query it however you want, integrate it with other systems, or migrate away anytime.
|
||||
|
||||
**Performance.** Data stays close to your users. No external requests that might get blocked by ad blockers.
|
||||
|
||||
**Full transparency.** OpenPanel is [open source](https://github.com/Openpanel-dev/openpanel). You can audit exactly what's being collected and how.
|
||||
|
||||
### Getting Started with Self-Hosting
|
||||
|
||||
We've tried to make self-hosting as simple as possible. The basic process is:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openpanel-dev/openpanel.git
|
||||
cd openpanel/self-hosting
|
||||
./setup
|
||||
./start
|
||||
```
|
||||
|
||||
We have detailed guides for different deployment options including [Docker Compose](/docs/self-hosting/deploy-docker-compose), [Coolify](/docs/self-hosting/deploy-coolify), [Dokploy](/docs/self-hosting/deploy-dokploy), and [Kubernetes](/docs/self-hosting/deploy-kubernetes).
|
||||
|
||||
Check out our full [self-hosting guide](/articles/how-to-self-host-openpanel) for a walkthrough of the entire process.
|
||||
|
||||
## The Hidden Cost of "Free" Analytics
|
||||
|
||||
Let's talk about Google Analytics for a moment. It's free, which is great. But that "free" comes with significant compliance costs that most organizations don't account for.
|
||||
|
||||
**Cookie consent management.** You need a consent management platform, ongoing maintenance, and likely degraded data quality from users who opt out.
|
||||
|
||||
**Privacy policy and legal review.** Your lawyers need to review how GA processes data and update your privacy documentation accordingly.
|
||||
|
||||
**Vendor assessment overhead.** For regulated industries, you need to continuously assess Google's practices and compliance posture.
|
||||
|
||||
**GDPR risk.** Given the ongoing regulatory actions against GA in Europe, you're taking on legal risk that's hard to quantify.
|
||||
|
||||
**Data subject requests.** Handling deletion requests through GA's tools is cumbersome and incomplete.
|
||||
|
||||
When you add up these costs, "free" analytics often isn't free at all. A transparent, paid solution like [OpenPanel](/pricing) or a self-hosted setup frequently works out cheaper while being more compliant.
|
||||
|
||||
## Other Regulations Worth Knowing
|
||||
|
||||
While GDPR, CCPA, HIPAA, and PECR are the big ones, there are others depending on your audience.
|
||||
|
||||
**LGPD (Brazil):** Similar to GDPR, with requirements for consent, data minimization, and user rights.
|
||||
|
||||
**PIPEDA (Canada):** Requires consent for collection and use of personal information, with some exceptions.
|
||||
|
||||
**US State Laws:** Over 20 US states now have comprehensive privacy laws, including Virginia, Colorado, Connecticut, and more. Most follow patterns similar to CCPA.
|
||||
|
||||
The good news is that if you're compliant with GDPR and CCPA, you're probably in good shape for most of these. And if you're using cookieless, self-hosted analytics, you're ahead of the game for all of them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Ready to simplify your analytics compliance? You have two paths with OpenPanel.
|
||||
|
||||
**OpenPanel Cloud** is the fastest way to get started. We handle the infrastructure, and your data is processed in compliance with GDPR and CCPA. You can be up and running in minutes with just a [simple script tag](/docs/get-started/install-openpanel).
|
||||
|
||||
**Self-hosted OpenPanel** gives you maximum control and compliance flexibility. It's ideal for healthcare organizations, enterprises with strict data residency requirements, or anyone who wants complete ownership of their analytics data.
|
||||
|
||||
Either way, you get [cookieless tracking](/articles/cookieless-analytics), [real-time dashboards](/docs), [funnels](/articles/how-to-create-a-funnel), user profiles, and all the features you need to understand your users without the compliance complexity.
|
||||
|
||||
[Get started with OpenPanel Cloud](https://dashboard.openpanel.dev/onboarding) or check out our [self-hosting documentation](/docs/self-hosting/self-hosting).
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="Does OpenPanel use cookies?">
|
||||
No. OpenPanel uses cookieless tracking by default. This means you don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel GDPR compliant?">
|
||||
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I need a BAA to use OpenPanel for healthcare analytics?">
|
||||
If you use OpenPanel Cloud, you would need to discuss BAA requirements with us. However, if you self-host OpenPanel on your own HIPAA-compliant infrastructure, no BAA is required because the data never leaves your environment.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I use OpenPanel without a cookie banner?">
|
||||
Yes. Since OpenPanel doesn't use cookies, you don't need a cookie consent banner for your analytics. However, you should still have a privacy policy that explains what data you collect.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Where is OpenPanel Cloud data stored?">
|
||||
OpenPanel Cloud infrastructure is based in the EU. For specific data residency requirements, self-hosting gives you complete control over where your data lives.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How does self-hosting help with compliance?">
|
||||
Self-hosting eliminates third-party data sharing, which simplifies compliance with GDPR, CCPA, HIPAA, and other regulations. Your data never leaves your infrastructure, so there's no vendor management, no international data transfers to worry about, and no BAAs required.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I migrate from Google Analytics to OpenPanel?">
|
||||
Yes. OpenPanel can replace Google Analytics for most use cases. We offer both [web analytics](/features/web-analytics) and product analytics features. Check our comparison with other platforms like the [Google Analytics alternative](/compare/google-analytics-alternative) page.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel open source?">
|
||||
Yes. OpenPanel is fully open source and available on GitHub. You can audit the code, contribute, or fork it for your own needs.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: A BullMQ Alternative for Grouped Job Processing
|
||||
description: An open-source queue system that eliminates race conditions through intelligent job grouping, perfect for high-throughput event processing pipelines
|
||||
title: "BullMQ Alternative: GroupMQ for Sequential Job Processing Without Race Conditions"
|
||||
description: "Tired of race conditions with BullMQ? GroupMQ is a free, open-source alternative that processes grouped jobs sequentially — no locks, no Pro license needed."
|
||||
date: 2025-10-31
|
||||
updated: 2026-02-12
|
||||
team: OpenPanel Team
|
||||
tag: Article
|
||||
cover: /content/bullmq-alternative.jpg
|
||||
@@ -53,7 +54,7 @@ Here's what makes GroupMQ special:
|
||||
|
||||
GroupMQ shines in scenarios where you need to maintain order within related operations:
|
||||
|
||||
**Analytics Processing**: Process events from the same user sequentially to maintain accurate session tracking and prevent duplicate counting.
|
||||
**Analytics Processing**: Process events from the same user sequentially to maintain accurate [session tracking](/features/session-tracking) and prevent duplicate counting.
|
||||
|
||||
**E-commerce Orders**: Handle order updates, payment processing, and inventory changes for the same order ID without race conditions.
|
||||
|
||||
@@ -181,7 +182,7 @@ It might not be the best fit if:
|
||||
|
||||
Installing GroupMQ is straightforward:
|
||||
|
||||
```bash
|
||||
```npm
|
||||
npm install groupmq
|
||||
```
|
||||
|
||||
|
||||
@@ -1,94 +1,199 @@
|
||||
---
|
||||
title: Cookieless Analytics
|
||||
description: Discover how to gather meaningful insights without cookies and why OpenPanel makes it effortless.
|
||||
title: "Cookieless Analytics: Best Tools & How They Work in 2026"
|
||||
description: "The complete guide to cookieless analytics platforms. Compare the best cookie-free analytics tools, learn how tracking without cookies works, and find the right solution for your site."
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-06-17
|
||||
updated: 2026-02-16
|
||||
cover: /content/cookieless-analytics.jpg
|
||||
---
|
||||
import { Faqs, FaqItem } from '@/components/faq';
|
||||
import { Figure } from '@/components/figure';
|
||||
|
||||
The age of tracking everyone, everywhere, with endless cookies is fading fast. Today’s users expect both useful experiences and respect for their privacy. Enter **cookieless analytics**—a smarter way to understand your audience without leaving a trail of crumbs behind. In this guide, we’ll unpack why this approach matters, and how you can get up and running in minutes with OpenPanel.
|
||||
Third-party cookies are dying. Safari and Firefox blocked them years ago. Chrome is finally following through. And privacy regulations keep getting stricter.
|
||||
|
||||
## What Is Cookieless Analytics, Really?
|
||||
If your analytics still depends on cookies, you're working with incomplete data — and possibly breaking the law. **Cookieless analytics** gives you accurate visitor insights without cookies, consent banners, or compliance headaches.
|
||||
|
||||
Put simply, it’s tracking without relying on third-party cookies. Instead of stuffing bits of data into a user’s browser, you pivot to methods like:
|
||||
This guide covers how cookieless tracking works, why it matters, and which tools do it best.
|
||||
|
||||
* **Server-side events.** Capture interactions directly on your backend.
|
||||
* **Session-based identifiers.** Tie actions together during a visit—then discard the identifier when they leave.
|
||||
* **First-party data.** Use your own signup forms, preferences, and logs.
|
||||
* **Device fingerprints** (used sparingly). Hash together non-identifying signals like screen size and language.
|
||||
## Why Cookies Are Going Away
|
||||
|
||||
Each of these respects privacy laws and keeps you off users’ “block” lists—without sacrificing insights.
|
||||
The shift away from cookies didn't happen overnight. It's been building for years.
|
||||
|
||||
## Why Ditch Cookies? Four Big Wins
|
||||
### Browser restrictions
|
||||
|
||||
### 1. Stay Ahead of Privacy Laws
|
||||
Safari's Intelligent Tracking Prevention (ITP) started blocking third-party cookies in 2017. Firefox followed with Enhanced Tracking Protection in 2019. Both browsers now block third-party cookies by default and limit first-party cookie lifetimes.
|
||||
|
||||
Regulations like GDPR and CCPA aren’t going away. By design, cookieless systems:
|
||||
Chrome — which holds roughly 65% of browser market share — announced its cookie deprecation plans in 2020. After multiple delays, Google is now phasing out third-party cookies through its Privacy Sandbox initiative. The timeline has shifted, but the direction is clear: third-party cookies are on borrowed time.
|
||||
|
||||
* Avoid endless consent banners
|
||||
* Keep you clear of hefty fines
|
||||
* Show customers you take privacy seriously
|
||||
### Privacy regulations
|
||||
|
||||
### 2. Delight Your Visitors
|
||||
GDPR requires explicit consent before setting non-essential cookies. That means cookie banners, consent management platforms, and the constant risk of getting it wrong. Fines can reach €20 million or 4% of global revenue.
|
||||
|
||||
Nothing disrupts a first impression like a pop-up you can’t close. With cookieless analytics:
|
||||
CCPA, Brazil's LGPD, and similar laws in other regions add their own requirements. The regulatory trend is unmistakable: more restrictions, not fewer.
|
||||
|
||||
* Pages load faster
|
||||
* There are no nagging “Accept cookies?” prompts
|
||||
* Your site works even when someone’s browser is locked down
|
||||
### Ad blockers and privacy tools
|
||||
|
||||
### 3. Future-Proof Your Data
|
||||
Over 40% of internet users run ad blockers or privacy tools. Most of these block analytics cookies too. That means cookie-based analytics is already missing a large chunk of your traffic.
|
||||
|
||||
Browsers are phasing out third-party cookies (Safari, Firefox already have). A cookieless stack means:
|
||||
### The result
|
||||
|
||||
* No last-minute scrambling when Chrome follows suit
|
||||
* Compatibility with privacy-focused browsers
|
||||
* A sustainable analytics foundation
|
||||
If you're still relying on cookies for analytics, you're getting incomplete data from a shrinking pool of users — and jumping through legal hoops to do it. Cookieless analytics solves all three problems at once.
|
||||
|
||||
### 4. Cleaner, More Trustworthy Insights
|
||||
## How Cookieless Tracking Works
|
||||
|
||||
When you rely on your own data sources:
|
||||
Cookieless analytics doesn't mean you stop collecting data. It means you collect it differently. Here are the main approaches:
|
||||
|
||||
* You reduce duplicate or incomplete sessions
|
||||
* You focus on active, consenting users
|
||||
* Your reports match your real user base
|
||||
### Server-side tracking
|
||||
|
||||
## Why OpenPanel Shines for Cookieless Tracking
|
||||
Instead of running JavaScript that sets cookies in the browser, server-side tracking captures events on your backend. The user's browser never receives a tracking cookie because the data collection happens on your server.
|
||||
|
||||
We built OpenPanel from the ground up with privacy at its heart—and with features you actually need:
|
||||
This approach is immune to ad blockers, doesn't require consent banners for basic analytics, and gives you more control over what data you collect.
|
||||
|
||||
### Privacy by Default
|
||||
### Session-based identifiers
|
||||
|
||||
* **Zero cookies.** Ever.
|
||||
* **GDPR & CCPA compliant.** Out of the box.
|
||||
* **Transparent data policies.** Your users know what’s collected and why.
|
||||
Some tools create a temporary, non-persistent identifier for each visit. This ties actions together during a single session — page views, clicks, form submissions — without storing anything in the browser. When the session ends, the identifier is discarded.
|
||||
|
||||
### Powerful, Yet Simple Analytics
|
||||
This gives you meaningful session-level data (bounce rate, pages per visit, conversion paths) without the privacy implications of persistent tracking.
|
||||
|
||||
* **Real-time dashboards.** Watch events as they happen.
|
||||
* **Custom events & properties.** Track anything from “add to wishlist” to “video watched.”
|
||||
* **Rich reports.** Dive deep on funnels, retention, and user journeys.
|
||||
### First-party data
|
||||
|
||||
### Plug-and-Play Setup
|
||||
Your own signup forms, user accounts, and preference settings are first-party data. You collected it directly from the user with their knowledge. Cookieless analytics tools can combine anonymous session data with authenticated user data when someone logs in — no cookies needed.
|
||||
|
||||
1. **Drop in our script:** Copy–paste, and you’re live.
|
||||
2. **Pick your SDK:** JavaScript, Python, Go… whatever fits.
|
||||
3. **Start tracking in minutes.** No extra configuration.
|
||||
### Hashed identifiers
|
||||
|
||||
### Open Source & Self-Hosted Option
|
||||
Some tools generate a daily hash from non-identifying signals like the visitor's IP address, user agent, and screen size. This lets you count unique visitors without storing personal data or setting cookies. The hash changes daily, so there's no long-term tracking.
|
||||
|
||||
* **Inspect the code.** Full transparency.
|
||||
* **Self-host if you choose.** Keep data on your servers.
|
||||
* **No vendor lock-in.** Export anytime.
|
||||
OpenPanel uses this approach: a daily rotating hash that counts unique visitors accurately without storing any personal information.
|
||||
|
||||
## Quick Start: Two Steps to Cookieless Insights
|
||||
## Best Cookieless Analytics Tools Compared
|
||||
|
||||
1. **Add the tracking snippet**
|
||||
Not all cookieless analytics tools are the same. Some focus on simple pageview tracking. Others offer full product analytics with funnels, retention, and user journeys. Here's how the main options compare:
|
||||
|
||||
| Tool | Type | Cookieless | Open Source | Self-Host | Cloud Pricing (from) | Best For |
|
||||
|------|------|-----------|-------------|-----------|---------------------|----------|
|
||||
| **OpenPanel** | Web + Product | Yes | Yes (AGPL-3.0) | Free | $2.50/mo | Teams wanting product analytics without cookies |
|
||||
| **Plausible** | Web | Yes | Yes (AGPL-3.0) | Free | $9/mo | Simple, lightweight pageview analytics |
|
||||
| **Fathom** | Web | Yes | No | No | $15/mo | Privacy-focused teams wanting managed hosting |
|
||||
| **Simple Analytics** | Web | Yes | No | No | $9/mo | Simplest possible analytics setup |
|
||||
| **Pirsch** | Web | Yes | Yes (AGPL-3.0) | License | $6/mo | Server-side analytics without JavaScript |
|
||||
|
||||
### OpenPanel
|
||||
|
||||
<Figure
|
||||
src="/content/tools/openpanel.png"
|
||||
caption="OpenPanel's web analytics dashboard — cookieless by default"
|
||||
/>
|
||||
|
||||
[OpenPanel](/) is an open source analytics platform that combines web analytics and product analytics — all without cookies. You get pageviews and traffic sources alongside funnels, retention, custom events, and user journeys.
|
||||
|
||||
**What sets it apart:** Most cookieless tools only track pageviews. OpenPanel gives you Mixpanel-level product analytics (event tracking, [funnels](/features/funnels), [retention](/features/retention), user properties) without setting a single cookie. And you can [self-host it for free](/articles/how-to-self-host-openpanel).
|
||||
|
||||
- Zero cookies, ever
|
||||
- GDPR and CCPA compliant out of the box
|
||||
- Real-time dashboards
|
||||
- Custom events and properties
|
||||
- Open source with full self-hosting support
|
||||
- Starts at $2.50/month on cloud
|
||||
|
||||
### Plausible
|
||||
|
||||
<Figure
|
||||
src="/content/tools/plausible.png"
|
||||
caption="Plausible's minimal analytics dashboard"
|
||||
/>
|
||||
|
||||
[Plausible](/compare/plausible-alternative) is a lightweight, privacy-first web analytics tool. It tracks pageviews, referral sources, and basic engagement metrics without cookies.
|
||||
|
||||
**Best for:** Sites that need simple traffic stats and nothing more. If you don't need event tracking, funnels, or user-level analytics, Plausible keeps things minimal.
|
||||
|
||||
- Under 1 KB script size
|
||||
- No cookies, no consent banner needed
|
||||
- Self-hostable (community edition)
|
||||
- Starts at $9/month on cloud
|
||||
|
||||
### Fathom
|
||||
|
||||
<Figure
|
||||
src="/content/tools/fathom.png"
|
||||
caption="Fathom's privacy-focused analytics dashboard"
|
||||
/>
|
||||
|
||||
[Fathom](/compare/fathom-alternative) is a privacy-focused, managed analytics tool. It handles cookieless tracking, EU isolation, and compliance so you don't have to think about it.
|
||||
|
||||
**Best for:** Teams that want a fully managed solution with zero maintenance. Fathom handles all the infrastructure and compliance details.
|
||||
|
||||
- Cookieless by default
|
||||
- EU data isolation available
|
||||
- Managed hosting only (no self-host)
|
||||
- Starts at $15/month
|
||||
|
||||
### Simple Analytics
|
||||
|
||||
[Simple Analytics](/compare/simple-analytics-alternative) does exactly what its name suggests: simple, privacy-friendly analytics with no cookies, no tracking scripts on the user's device, and a clean dashboard.
|
||||
|
||||
**Best for:** Teams that want the absolute simplest analytics setup. No configuration, no complex features — just traffic data.
|
||||
|
||||
- No cookies, no fingerprinting
|
||||
- Lightweight script
|
||||
- AI-powered insights
|
||||
- Starts at $9/month
|
||||
|
||||
### Pirsch
|
||||
|
||||
<Figure
|
||||
src="/content/tools/pirsch.png"
|
||||
caption="Pirsch's server-side analytics dashboard"
|
||||
/>
|
||||
|
||||
Pirsch takes a different approach entirely: server-side only analytics. There's no JavaScript snippet to load. Instead, you send events from your backend, which means ad blockers can't interfere.
|
||||
|
||||
**Best for:** Developers who want 100% accurate tracking that can't be blocked by browser extensions.
|
||||
|
||||
- Server-side only, no JavaScript needed
|
||||
- Cookie-free by design
|
||||
- Open source core
|
||||
- Starts at $6/month
|
||||
|
||||
## Cookieless Analytics vs Traditional Analytics
|
||||
|
||||
Switching from cookie-based analytics (like Google Analytics) to a cookieless platform isn't just a privacy upgrade. It changes what data you get and how you use it.
|
||||
|
||||
### What you gain
|
||||
|
||||
**Accurate visitor counts.** Cookie-based analytics misses users who block cookies, use private browsing, or decline consent banners. Cookieless tools track everyone because there's nothing to block or decline.
|
||||
|
||||
**No consent banners.** If your analytics tool doesn't set cookies, most privacy laws don't require a consent banner for basic analytics. That means no pop-ups, faster page loads, and better user experience.
|
||||
|
||||
**Simpler compliance.** No cookies means no cookie audits, no consent management platforms, no records of consent, and no worrying about which cookies are "strictly necessary." Your legal team will thank you.
|
||||
|
||||
**Faster pages.** Cookie-based analytics scripts are typically larger and heavier. Most cookieless tools use lightweight scripts under 5 KB. Some, like Pirsch, use no client-side script at all.
|
||||
|
||||
**Future-proof data.** Your analytics won't break when Chrome finishes deprecating third-party cookies. You're already ahead.
|
||||
|
||||
### What you lose
|
||||
|
||||
**Cross-session user tracking (mostly).** Without persistent cookies, you can't easily track the same anonymous visitor across multiple sessions over weeks or months. If a user visits Monday and returns Thursday, most cookieless tools count that as two separate visitors.
|
||||
|
||||
However, this matters less than you think. Once a user logs in or signs up, you can track them across sessions using your own first-party data. Tools like OpenPanel support this with authenticated user identification.
|
||||
|
||||
**Some Google Analytics features.** GA's remarketing audiences, cross-domain tracking, and integration with Google Ads all rely on cookies. If you depend on these, you'll need alternative approaches.
|
||||
|
||||
**Attribution modeling.** Multi-touch attribution across long time windows gets harder without persistent identifiers. But honestly, cookie-based attribution was never as accurate as people assumed — it was already broken by ad blockers and browser restrictions.
|
||||
|
||||
### The bottom line
|
||||
|
||||
For most sites, cookieless analytics gives you *more* accurate data (because nothing is blocked) while removing the legal and UX overhead of cookie consent. The tradeoff — less cross-session anonymous tracking — is increasingly irrelevant as cookies disappear anyway.
|
||||
|
||||
## Getting Started with Cookieless Analytics
|
||||
|
||||
Setting up OpenPanel takes about two minutes. No cookies, no consent banners, no complex configuration.
|
||||
|
||||
### 1. Add the tracking snippet
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[],o=new Proxy((function(){arguments.length>0&&n.push(Array.prototype.slice.call(arguments))}),{get:function(o,t){return"q"===t?n:function(){n.push([t].concat(Array.prototype.slice.call(arguments)))}}});return o}();
|
||||
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}}) }();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
@@ -99,18 +204,54 @@ We built OpenPanel from the ground up with privacy at its heart—and with featu
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
```
|
||||
|
||||
2. **Fire off your first event**
|
||||
### 2. Track custom events
|
||||
|
||||
```javascript
|
||||
// Simple click
|
||||
window.op('track', 'signup_button_clicked');
|
||||
|
||||
// Purchase with details
|
||||
window.op('track', 'order_placed', {
|
||||
orderId: 'ORD-20250617-001',
|
||||
orderId: 'ORD-20260216-001',
|
||||
revenue: 49.95,
|
||||
currency: 'EUR',
|
||||
});
|
||||
```
|
||||
|
||||
That’s it—you’re capturing all the user interactions you need, cookie-free.
|
||||
That's it. You're collecting analytics data without cookies, without consent banners, and without compromising on the insights you need.
|
||||
|
||||
Want full control over your data? You can also [self-host OpenPanel](/articles/how-to-self-host-openpanel) on your own infrastructure for free.
|
||||
|
||||
## FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What is cookieless analytics?">
|
||||
Cookieless analytics is a way to track website and app usage without storing cookies in the visitor's browser. Instead, it uses techniques like server-side tracking, session-based identifiers, and hashed signals to collect data. This avoids the need for cookie consent banners and improves compliance with privacy regulations like GDPR and CCPA.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is cookieless analytics GDPR compliant?">
|
||||
Cookieless analytics makes GDPR compliance significantly easier. Since no cookies are set, you typically don't need a cookie consent banner for basic analytics. However, GDPR applies to all personal data processing, not just cookies. Choose a tool that minimizes data collection and offers EU data hosting or self-hosting. OpenPanel is GDPR compliant by design — it collects no personal data and can be [self-hosted](/articles/how-to-self-host-openpanel) on your own EU servers.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I still need a cookie banner with cookieless analytics?">
|
||||
For basic analytics, no. Cookie consent banners are required when your site sets non-essential cookies. If your analytics tool doesn't use cookies at all — like OpenPanel, Plausible, or Fathom — you don't need a cookie banner specifically for analytics. You may still need one if other parts of your site use cookies (marketing tools, chat widgets, etc.).
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How accurate is cookieless analytics compared to Google Analytics?">
|
||||
Cookieless analytics is often *more* accurate than Google Analytics for basic metrics like pageviews and unique visitors. That's because cookie-based analytics misses users who block cookies, use ad blockers, or decline consent banners — which can be 30-40% of traffic. Cookieless tools capture these visitors because there's nothing to block or decline.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can cookieless analytics track individual users?">
|
||||
Cookieless analytics tracks anonymous sessions by default. However, once a user logs in or identifies themselves, tools like OpenPanel can associate their activity with a user profile using first-party data. This gives you user-level analytics (funnels, retention, journeys) for authenticated users without ever needing a cookie.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What is the best cookieless analytics platform?">
|
||||
It depends on your needs. For teams that want both web analytics and product analytics (funnels, retention, events) without cookies, [OpenPanel](/) is the best option — it's open source, self-hostable, and starts at $2.50/month. For simple pageview tracking, [Plausible](/compare/plausible-alternative) is the most popular choice. For fully managed, zero-maintenance analytics, [Fathom](/compare/fathom-alternative) is worth considering.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is Google Analytics cookieless?">
|
||||
No. Google Analytics (GA4) still uses first-party cookies by default. Google has introduced a "cookieless measurement" mode, but it relies on Google's modeling and machine learning to estimate data — it doesn't actually track without cookies in the same way purpose-built cookieless tools do. For genuine cookieless analytics, you need a tool designed for it from the ground up.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I use cookieless analytics with a self-hosted setup?">
|
||||
Yes. Several cookieless analytics tools support self-hosting, which gives you full data ownership and the strongest possible privacy posture. OpenPanel, Plausible, and Pirsch all offer self-hosted options. With self-hosting, your analytics data never leaves your own servers. See our guide on [self-hosted analytics and compliance](/articles/better-compliance-self-hosted-analytics) for more details.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
@@ -4,11 +4,12 @@ description: Funnels are powerful tools that help you understand how users move
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-03-31
|
||||
updated: 2026-02-07
|
||||
cover: /content/funnels.jpg
|
||||
---
|
||||
import { Figure } from "@/components/figure";
|
||||
|
||||
Funnels are powerful tools that help you understand how users move through your website or app. In this guide, we'll walk you through everything you need to know about creating and using funnels effectively.
|
||||
[Funnels](/features/funnels) are powerful tools that help you understand how users move through your website or app. In this guide, we'll walk you through everything you need to know about creating and using funnels effectively.
|
||||
|
||||
## What is a Funnel?
|
||||
|
||||
@@ -66,7 +67,7 @@ Before you change report type you'll see a linear chart with a line for each eve
|
||||
|
||||
### 4. Understanding Your Funnel Results
|
||||
|
||||
When your funnel is ready, you'll see a visualization that tells an important story. Let's say 1,000 people view a recipe - that's the top of your funnel. If 175 of those people save the recipe, your conversion rate is 17.5%. This number tells you how well this part of your process is working.
|
||||
When your funnel is ready, you'll see a visualization that tells an important story. Let's say 1,000 people view a recipe - that's the top of your funnel. If 175 of those people save the recipe, your [conversion rate](/features/conversion) is 17.5%. This number tells you how well this part of your process is working.
|
||||
|
||||
> In this example we just did a 2-step-funnel, you can have as many steps as you want, but we recommend around 3-5.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: How to Export Data from Umami Analytics
|
||||
description: Learn how to export your analytics data from Umami for migration or backup
|
||||
date: 2025-10-30
|
||||
updated: 2026-02-07
|
||||
cover: /content/export-data-from-umami.jpg
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
@@ -16,7 +17,7 @@ import { Figure } from '@/components/figure'
|
||||
caption="Running the OpenPanel Umami exporter to export analytics data from Umami"
|
||||
/>
|
||||
|
||||
When it comes to web analytics, having control over your data is crucial. Whether you're planning to switch analytics platforms, need to create backups, or want to analyze your data in specialized tools, being able to export your Umami Analytics data is essential.
|
||||
When it comes to [web analytics](/features/web-analytics), having control over your data is crucial. Whether you're planning to switch analytics platforms, need to create backups, or want to analyze your data in specialized tools, being able to export your Umami Analytics data is essential.
|
||||
|
||||
The challenge is that Umami handles data export differently depending on whether you're using their cloud service or self-hosting. This guide will walk you through both scenarios and help you understand why you might want to consider alternatives like OpenPanel for your analytics needs.
|
||||
|
||||
@@ -83,7 +84,7 @@ We built OpenPanel with the understanding that modern websites need more than ju
|
||||
|
||||
One key difference is real-time data processing. With our platform, you see visitor activity as it happens, not with the delays common in batch-processing systems. This immediacy helps you respond quickly to traffic spikes, marketing campaigns, or technical issues.
|
||||
|
||||
Our event tracking goes beyond simple pageviews. You can track custom events, user interactions, and conversion funnels without writing complex code. Our platform automatically captures many interactions that would require manual setup in Umami.
|
||||
Our [event tracking](/features/event-tracking) goes beyond simple pageviews. You can track custom events, user interactions, and [conversion funnels](/features/funnels) without writing complex code. Our platform automatically captures many interactions that would require manual setup in Umami.
|
||||
|
||||
Our filtering and segmentation capabilities are also more advanced. You can create complex queries to understand specific user segments, compare time periods with more flexibility, and build custom dashboards that focus on your key metrics.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Learn essential steps to secure your Ubuntu server, including user
|
||||
tag: Hosting
|
||||
team: OpenPanel Team
|
||||
date: 2024-11-14
|
||||
updated: 2026-02-12
|
||||
cover: /content/secure-server.jpg
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: How to Self-Host OpenPanel Analytics Platform
|
||||
description: Learn how to self-host OpenPanel web analytics platform. Step-by-step guide to install and configure your own analytics server for better privacy and cost savings.
|
||||
date: 2025-02-28
|
||||
updated: 2026-02-07
|
||||
cover: /content/how-to-self-host-openpanel.jpg
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
@@ -21,7 +22,9 @@ cd openpanel/self-hosting
|
||||
|
||||
## Why Self-Host Your Own Analytics Platform?
|
||||
|
||||
Looking for a [Mixpanel alternative](/articles/alternatives-to-mixpanel)? Self-hosting your own web analytics and product analytics platform comes with several benefits. Let's break down the pros and cons of running your own analytics server.
|
||||
Looking for a [Mixpanel alternative](/articles/alternatives-to-mixpanel)? Self-hosting your own [web analytics](/features/web-analytics) and product analytics platform comes with several benefits. Let's break down the pros and cons of running your own analytics server.
|
||||
|
||||
For a comparison of all open source analytics platforms, see our [comprehensive guide to open source web analytics tools](/articles/open-source-web-analytics).
|
||||
|
||||
### Cost Benefits
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ description: OpenPanel is a versatile analytics platform that offers a wide arra
|
||||
tag: Introduction
|
||||
team: OpenPanel Team
|
||||
date: 2024-11-09
|
||||
updated: 2026-02-07
|
||||
---
|
||||
|
||||
Welcome to OpenPanel, the open-source analytics platform designed to be a robust alternative to Mixpanel and a great substitute for Google Analytics. In this article, we'll explore why OpenPanel is the ideal choice for businesses looking to leverage powerful analytics while maintaining control over their data.
|
||||
@@ -14,7 +15,9 @@ At OpenPanel, we are committed to the principles of open-source software. By mak
|
||||
|
||||
## Why Choose OpenPanel?
|
||||
|
||||
Our journey began with a vision to create an open-source alternative to Mixpanel, a tool we admired for its product analytics capabilities. However, as we developed OpenPanel, we realized the potential to offer more comprehensive features that Mixpanel lacked, particularly in the realm of web analytics. While Mixpanel excels in product analytics, it doesn't fully address web analytics needs. OpenPanel bridges this gap by integrating both web and product analytics, providing a holistic view of user behavior.
|
||||
Our journey began with a vision to create an open-source alternative to Mixpanel, a tool we admired for its product analytics capabilities. However, as we developed OpenPanel, we realized the potential to offer more comprehensive features that Mixpanel lacked, particularly in the realm of [web analytics](/features/web-analytics). While Mixpanel excels in product analytics, it doesn't fully address web analytics needs. OpenPanel bridges this gap by integrating both web and product analytics, providing a holistic view of user behavior.
|
||||
|
||||
For a detailed comparison with other tools, see our guide on [open source web analytics](/articles/open-source-web-analytics).
|
||||
|
||||
## What Can You Do with OpenPanel?
|
||||
|
||||
@@ -22,8 +25,8 @@ OpenPanel is a versatile analytics platform that offers a wide array of features
|
||||
|
||||
- **Web Analytics**: Gain insights similar to tools like Plausible, Fathom, and Simple Analytics.
|
||||
- **Product Analytics**: Analyze product usage and user interactions, akin to Mixpanel.
|
||||
- **User Retention**: Track and enhance user retention rates.
|
||||
- **Funnels**: Visualize user journeys and conversion paths.
|
||||
- **[User Retention](/features/retention)**: Track and enhance user retention rates.
|
||||
- **[Funnels](/features/funnels)**: Visualize user journeys and [conversion paths](/features/conversion).
|
||||
- **Events**: Monitor specific user actions and interactions.
|
||||
- **Profiles**: Create detailed user profiles to better understand your audience.
|
||||
- **Real-Time View**: Display real-time data on a big monitor in your office for dynamic insights.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: Mixpanel Alternatives - 4 Best Options for Product Analytics
|
||||
description: Looking for Mixpanel alternatives? Compare pricing, features, and privacy options among the best product analytics tools including open-source solutions.
|
||||
date: 2025-07-18
|
||||
title: "13 Best Mixpanel Alternatives & Competitors in 2026"
|
||||
description: "Compare the best Mixpanel alternatives for product analytics in 2026. Side-by-side pricing, features, and privacy comparison of 7 top tools plus 6 honorable mentions — including open source and free options."
|
||||
updated: 2026-02-16
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
cover: /content/cover-alternatives.jpg
|
||||
@@ -9,11 +10,11 @@ cover: /content/cover-alternatives.jpg
|
||||
import { Faqs, FaqItem } from '@/components/faq';
|
||||
import { Figure } from '@/components/figure'
|
||||
|
||||
Mixpanel revolutionized product analytics by making it easy to track user behavior beyond simple pageviews. But as powerful as it is, many teams are searching for Mixpanel alternatives that better fit their needs, budget, or privacy requirements.
|
||||
Mixpanel revolutionized product analytics by making it easy to track user behavior beyond simple pageviews. But as powerful as it is, many teams are searching for Mixpanel alternatives and competitors that better fit their needs, budget, or privacy requirements. Whether you need a free open source option you can self-host or an affordable cloud tool, there are strong alternatives worth considering in 2026.
|
||||
|
||||
The surge in demand for Mixpanel alternatives stems from several pain points that teams frequently encounter:
|
||||
|
||||
**Pricing Shock**: Mixpanel's pricing can escalate quickly as your user base grows. What starts as a manageable $28/month can balloon to thousands of dollars once you exceed the free tier limits. For startups and growing companies, this unpredictable cost structure makes budgeting difficult.
|
||||
**Pricing Shock**: [Mixpanel's pricing](/articles/mixpanel-pricing) can escalate quickly as your user base grows. What starts as a manageable $28/month can balloon to thousands of dollars once you exceed the free tier limits. For startups and growing companies, this unpredictable cost structure makes budgeting difficult.
|
||||
|
||||
**Data Privacy Concerns**: With increasing privacy regulations like GDPR and CCPA, many companies need analytics tools that prioritize user privacy. Mixpanel's cloud-based model means your user data lives on their servers, which can be a compliance headache for privacy-conscious organizations.
|
||||
|
||||
@@ -23,7 +24,21 @@ The surge in demand for Mixpanel alternatives stems from several pain points tha
|
||||
|
||||
**Self-hosting Requirements**: Some organizations, particularly in regulated industries, need to keep analytics data within their own infrastructure. Mixpanel's cloud-only approach doesn't accommodate these security and compliance requirements.
|
||||
|
||||
In this guide, we'll explore the top 5 Mixpanel alternatives specifically designed for product analytics. Whether you're looking to reduce costs, improve privacy, or gain more flexibility, there's an alternative that fits your needs. If you're also interested in web analytics tools, check out our guide to [open-source web analytics](/articles/open-source-web-analytics).
|
||||
In this guide, we'll explore 13 Mixpanel alternatives — 7 in-depth reviews of the strongest competitors plus 6 honorable mentions worth knowing about. Whether you're looking to reduce costs, improve privacy, or gain more flexibility, there's an alternative that fits your needs. If you're also interested in [web analytics](/features/web-analytics) tools, check out our guide to [open-source web analytics](/articles/open-source-web-analytics).
|
||||
|
||||
Here's a quick comparison of all 7 top alternatives:
|
||||
|
||||
| Tool | Type | Open Source | Self-Host | Free Tier | Paid From | Best For |
|
||||
|------|------|-------------|-----------|-----------|-----------|----------|
|
||||
| [**OpenPanel**](#1-openpanel---the-privacy-first-alternative) | Web + Product | Yes (AGPL-3.0) | Free | 30-day trial | $2.50/mo | Affordable, privacy-first product analytics |
|
||||
| [**PostHog**](#2-posthog---the-all-in-one-platform) | Product + More | Yes (MIT) | Free | 1M events/mo | Usage-based | All-in-one: analytics + replay + flags |
|
||||
| [**Heap**](#3-heap---the-autocapture-alternative) | Product | No | No | 10K sessions/mo | ~$3,600/yr | Autocapture and retroactive analysis |
|
||||
| [**Amplitude**](#4-amplitude---the-enterprise-alternative) | Product | No | No | 10M events/mo | Contact sales | Enterprise ML-powered analytics |
|
||||
| [**Pendo**](#5-pendo---the-product-experience-alternative) | Product + UX | No | No | 500 users | Contact sales | Analytics + in-app guides + feedback |
|
||||
| [**Matomo**](#6-matomo---the-privacy-focused-on-premise-alternative) | Web + Product | Yes (GPL-3.0) | Free | Self-hosted free | €23/mo (cloud) | Privacy-first, GDPR-compliant analytics |
|
||||
| [**GA4**](#7-google-analytics-4---the-free-enterprise-alternative) | Web + Product | No | No | 25M events/mo | ~$50K/yr (360) | Free analytics at scale, Google ecosystem |
|
||||
|
||||
Now let's look at what matters when choosing a Mixpanel alternative, then dive deep into each tool.
|
||||
|
||||
<Figure
|
||||
src="/content/mixpanel.png"
|
||||
@@ -37,9 +52,9 @@ Before diving into specific tools, let's establish what features are essential f
|
||||
|
||||
### Core Product Analytics Features
|
||||
|
||||
**Event Tracking**: The foundation of product analytics. You need to track custom events like signups, purchases, feature usage, and any user action that matters to your business. The tool should support [event properties and user properties](/docs/api/track#tracking-events) for rich data collection.
|
||||
**[Event Tracking](/features/event-tracking)**: The foundation of product analytics. You need to track custom events like signups, purchases, feature usage, and any user action that matters to your business. The tool should support [event properties and user properties](/docs/api/track#tracking-events) for rich data collection.
|
||||
|
||||
**Funnel Analysis**: Understanding conversion rates through multi-step processes is crucial. Whether it's onboarding, checkout, or feature adoption, you need to visualize where users drop off and optimize accordingly.
|
||||
**[Funnel Analysis](/features/funnels)**: Understanding [conversion rates](/features/conversion) through multi-step processes is crucial. Whether it's onboarding, checkout, or feature adoption, you need to visualize where users drop off and optimize accordingly.
|
||||
|
||||
**Retention Analytics**: Track how often users return and engage with your product over time. Look for cohort analysis, retention curves, and the ability to segment users by behavior patterns.
|
||||
|
||||
@@ -98,7 +113,7 @@ All plans include unlimited websites, team members, and 5 years of data retentio
|
||||
|
||||
**Funnel Builder**: Create multi-step funnels to analyze conversion rates. Visualize drop-off points and optimize user flows.
|
||||
|
||||
**Retention Analysis**: Understand user stickiness with cohort retention charts. Track daily, weekly, or monthly retention patterns.
|
||||
**[Retention Analysis](/features/retention)**: Understand user stickiness with cohort retention charts. Track daily, weekly, or monthly retention patterns.
|
||||
|
||||
**Individual User Profiles**: Drill down into specific user journeys. See complete event timelines for debugging or customer support.
|
||||
|
||||
@@ -338,6 +353,181 @@ Amplitude's pricing isn't publicly available for paid tiers, but their free tier
|
||||
- Teams needing advanced data governance
|
||||
- Organizations with large free tier requirements
|
||||
|
||||
## 5. Pendo - The Product Experience Alternative
|
||||
|
||||
### Overview
|
||||
|
||||
Pendo goes beyond traditional product analytics by combining behavioral tracking with in-app guidance, user feedback, and product planning tools. It's designed for product managers who want to understand how users interact with their product and then act on those insights directly within the application.
|
||||
|
||||
Where Mixpanel focuses purely on analytics, Pendo helps you close the loop: analyze behavior, then deploy targeted in-app guides, tooltips, and announcements to improve adoption and onboarding without writing code.
|
||||
|
||||
- **Homepage**: [pendo.io](https://pendo.io)
|
||||
- **Free Tier**: Up to 500 monthly active users
|
||||
- **License**: Proprietary
|
||||
|
||||
### Pricing
|
||||
|
||||
Pendo's pricing is based on monthly active users (MAUs) rather than events:
|
||||
|
||||
| Plan | MAUs | Price | Features |
|
||||
|------|------|-------|----------|
|
||||
| Free | Up to 500 | $0 | Core analytics, 1 guide |
|
||||
| Base | Custom | Contact sales | Analytics, guides, NPS |
|
||||
| Core | Custom | Contact sales | Full platform, roadmapping |
|
||||
| Pulse | Custom | Contact sales | Enterprise features, SLAs |
|
||||
|
||||
*Note: Pendo's paid plans typically start at $7,000-12,000/year depending on MAU count and features.*
|
||||
|
||||
### Key Features
|
||||
|
||||
**Product Analytics**: Event tracking, funnels, paths, and retention analysis similar to Mixpanel's core offering.
|
||||
|
||||
**In-App Guides**: Create tooltips, walkthroughs, and onboarding flows without code. Target users based on behavior segments.
|
||||
|
||||
**NPS & Surveys**: Collect user feedback directly within your product with built-in NPS, polls, and surveys.
|
||||
|
||||
**Product Planning**: Roadmapping tools that connect user feedback and analytics data to product decisions.
|
||||
|
||||
**Session Replay**: Watch user sessions to understand behavior in context (add-on feature).
|
||||
|
||||
### Pros
|
||||
- Combines analytics with actionable in-app guidance
|
||||
- No-code guide builder for product managers
|
||||
- Built-in NPS and user feedback collection
|
||||
- Strong onboarding optimization capabilities
|
||||
- Good mobile analytics support
|
||||
|
||||
### Cons
|
||||
- Expensive — paid plans start at $7,000+/year
|
||||
- Not open source — full vendor lock-in
|
||||
- Analytics are less deep than dedicated tools like Mixpanel
|
||||
- Heavier SDK can impact page performance
|
||||
- Free tier limited to 500 MAUs
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How does Pendo compare to Mixpanel for analytics?">
|
||||
Mixpanel is stronger for pure analytics — deeper funnels, more flexible queries, and better data exploration. Pendo's analytics are competent but simpler. The real value of Pendo is combining analytics with in-app guides and feedback, which Mixpanel doesn't offer at all.
|
||||
</FaqItem>
|
||||
<FaqItem question="Can Pendo replace Mixpanel entirely?">
|
||||
For teams that primarily need basic product analytics plus in-app guidance, yes. But if you rely heavily on advanced event analysis, custom queries, or detailed funnel breakdowns, you may find Pendo's analytics too limited and still need a dedicated tool alongside it.
|
||||
</FaqItem>
|
||||
<FaqItem question="Is Pendo worth the price?">
|
||||
Pendo is one of the most expensive options in this list. It's worth it if you actively use the in-app guide and feedback features — that's where the unique value lies. If you only need analytics, there are much more affordable alternatives like OpenPanel or PostHog.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Best For
|
||||
- Product managers who need analytics plus in-app guidance
|
||||
- Teams optimizing user onboarding without engineering resources
|
||||
- Companies that want to collect user feedback alongside behavioral data
|
||||
- Organizations willing to pay premium prices for an integrated product experience platform
|
||||
|
||||
## 6. Matomo - The Privacy-Focused On-Premise Alternative
|
||||
|
||||
### Overview
|
||||
|
||||
Matomo (formerly Piwik) is one of the longest-standing open-source analytics platforms, used by over 1 million websites worldwide. Unlike Mixpanel, Matomo was built with privacy at its core — it's the go-to choice for government agencies, universities, and organizations in regulated industries that need full GDPR compliance without legal uncertainty.
|
||||
|
||||
While Matomo started as a web analytics tool (and still excels there), it has expanded into product analytics territory with features like custom events, funnels, and cohort analysis. It's not as deep as Mixpanel for product analytics, but for teams that need both web and product analytics with complete data ownership, Matomo is a proven choice. See our detailed [OpenPanel vs Matomo comparison](/compare/matomo-alternative) for a feature-by-feature breakdown.
|
||||
|
||||
- **Homepage**: [matomo.org](https://matomo.org)
|
||||
- **GitHub**: [github.com/matomo-org/matomo](https://github.com/matomo-org/matomo)
|
||||
- **License**: GPL-3.0 (fully open source)
|
||||
- **Free Tier**: Self-hosted is free forever
|
||||
|
||||
### Pricing
|
||||
|
||||
| Plan | Price | Features |
|
||||
|------|-------|----------|
|
||||
| Self-hosted (On-Premise) | Free | Full platform, unlimited sites, community support |
|
||||
| Cloud - Essential | From €23/month | 50k hits, managed hosting, email support |
|
||||
| Cloud - Business | From €45/month | 50k hits, advanced features, phone support |
|
||||
| Cloud - Enterprise | Contact sales | Custom limits, SLAs, dedicated support |
|
||||
|
||||
### Key Features
|
||||
|
||||
**Web Analytics**: Comprehensive pageview tracking, referrers, campaigns, and real-time visitors — comparable to Google Analytics.
|
||||
|
||||
**Custom Events & Goals**: Track custom events and define conversion goals with multi-step funnels.
|
||||
|
||||
**Heatmaps & Session Recording**: Built-in heatmaps, scroll depth, and session replays (premium features on cloud, available as plugins on self-hosted).
|
||||
|
||||
**Tag Manager**: Server-side tag manager included, reducing reliance on third-party scripts.
|
||||
|
||||
**GDPR Manager**: Built-in tools for data subject access requests, consent management, and data anonymization.
|
||||
|
||||
### Pros
|
||||
- Battle-tested open-source platform with 15+ years of development
|
||||
- 100% data ownership when self-hosted — no data sent to third parties
|
||||
- GDPR compliance built in, trusted by EU government agencies
|
||||
- Large plugin ecosystem for extending functionality
|
||||
- Can import Google Analytics data for easier migration
|
||||
|
||||
### Cons
|
||||
- Product analytics features are less mature than Mixpanel
|
||||
- Self-hosted version requires PHP/MySQL stack and ongoing maintenance
|
||||
- Cloud pricing can get expensive at scale (charged per hit)
|
||||
- Interface feels dated compared to modern analytics tools
|
||||
- Some advanced features (heatmaps, A/B testing) require paid plugins
|
||||
|
||||
### Best For
|
||||
- Organizations in regulated industries (healthcare, government, finance)
|
||||
- Teams migrating from Google Analytics who want data ownership
|
||||
- Companies in the EU that need bulletproof GDPR compliance
|
||||
- Self-hosting teams already running PHP/MySQL infrastructure
|
||||
|
||||
## 7. Google Analytics 4 - The Free Enterprise Alternative
|
||||
|
||||
### Overview
|
||||
|
||||
Google Analytics 4 (GA4) is the default analytics platform for millions of websites and the most common tool teams already have in place before considering Mixpanel. While GA4 has evolved significantly from Universal Analytics, adding event-based tracking and product analytics features, it still lags behind Mixpanel in areas like user-level analysis, custom funnels, and real-time event debugging.
|
||||
|
||||
That said, GA4 is hard to ignore as a Mixpanel alternative purely because of its price tag: free for up to 25 million events per month. For teams on a tight budget that need basic product analytics alongside comprehensive web analytics, GA4 can cover a lot of ground before you need a dedicated tool. For a detailed look at how OpenPanel compares, see our [Google Analytics alternative](/compare/google-analytics-alternative) page.
|
||||
|
||||
- **Homepage**: [analytics.google.com](https://analytics.google.com)
|
||||
- **Free Tier**: 25 million events per month
|
||||
- **License**: Proprietary (Google)
|
||||
|
||||
### Pricing
|
||||
|
||||
| Plan | Price | Features |
|
||||
|------|-------|----------|
|
||||
| GA4 (Standard) | Free | 25M events/month, 14 months retention, standard reports |
|
||||
| GA4 360 (Enterprise) | From ~$50,000/year | Unlimited events, 50 months retention, SLAs, BigQuery streaming |
|
||||
|
||||
### Key Features
|
||||
|
||||
**Event-Based Tracking**: GA4 uses an event-driven model similar to Mixpanel — every interaction is an event with parameters.
|
||||
|
||||
**Exploration Reports**: Freeform analysis, funnel exploration, path exploration, and cohort analysis — closer to Mixpanel-style ad hoc querying.
|
||||
|
||||
**BigQuery Integration**: Export raw event data to BigQuery for custom SQL analysis at no additional cost.
|
||||
|
||||
**Predictive Metrics**: ML-powered predictions for purchase probability, churn probability, and revenue forecasts.
|
||||
|
||||
**Cross-Platform Tracking**: Unified web and app analytics with Google's identity resolution.
|
||||
|
||||
### Pros
|
||||
- Free for most businesses (25M events/month is generous)
|
||||
- Seamless integration with Google Ads, Search Console, and the Google ecosystem
|
||||
- Event-based model is a major improvement over Universal Analytics
|
||||
- BigQuery export enables advanced custom analysis
|
||||
- Largest community and resource base of any analytics tool
|
||||
|
||||
### Cons
|
||||
- Not open source — your data lives on Google's servers
|
||||
- Privacy concerns: data is used for Google's advertising products
|
||||
- Not GDPR-compliant by default (requires careful configuration)
|
||||
- Limited to 14 months of data retention on the free tier
|
||||
- User-level analysis is restricted compared to Mixpanel
|
||||
- Complex setup for accurate event tracking
|
||||
|
||||
### Best For
|
||||
- Teams that need free analytics at scale
|
||||
- Companies already deep in the Google ecosystem (Ads, Search Console)
|
||||
- Organizations that need basic product analytics alongside web analytics
|
||||
- Businesses where privacy and data ownership are not primary concerns
|
||||
|
||||
## Making the Right Choice
|
||||
|
||||
Choosing the right Mixpanel alternative depends on your specific needs:
|
||||
@@ -373,33 +563,100 @@ Choosing the right Mixpanel alternative depends on your specific needs:
|
||||
- You need to collect user feedback and manage feature requests
|
||||
- You have the budget for a premium product experience platform
|
||||
|
||||
### Choose Matomo if:
|
||||
- You need bulletproof GDPR compliance for regulated industries
|
||||
- You're migrating from Google Analytics and want data ownership
|
||||
- You need a battle-tested open-source platform with a large plugin ecosystem
|
||||
- Your team is comfortable with PHP/MySQL self-hosting
|
||||
|
||||
### Choose GA4 if:
|
||||
- You need free analytics at massive scale (25M events/month)
|
||||
- You're already invested in the Google ecosystem (Ads, Search Console, BigQuery)
|
||||
- You need basic product analytics alongside comprehensive web analytics
|
||||
- Privacy and data ownership are not your primary concerns
|
||||
|
||||
## Honorable Mentions
|
||||
|
||||
Beyond the seven alternatives above, several other tools are worth considering depending on your specific use case:
|
||||
|
||||
### [Countly](https://count.ly/)
|
||||
|
||||
An open-source mobile and web analytics platform with a focus on mobile apps. Offers self-hosting, push notifications, crash analytics, and user profiles. Good for mobile-first teams that want a Mixpanel alternative with built-in engagement tools. Community edition is free and open source (AGPL-3.0).
|
||||
|
||||
### [Kissmetrics](https://www.kissmetrics.io/)
|
||||
|
||||
One of the original Mixpanel competitors, Kissmetrics focuses on revenue-driven analytics for SaaS and ecommerce. It connects user behavior directly to revenue metrics, making it valuable for teams that think in terms of customer lifetime value rather than raw event counts. Pricing starts around $299/month.
|
||||
|
||||
### [FullStory](https://www.fullstory.com/)
|
||||
|
||||
Combines product analytics with session replay and heatmaps. FullStory's auto-capture approach is similar to Heap's, but with a stronger focus on digital experience intelligence. Best for UX teams and enterprises with budget for premium tooling. No free tier for analytics.
|
||||
|
||||
### [Statsig](https://www.statsig.com/)
|
||||
|
||||
Built by ex-Facebook engineers, Statsig combines product analytics with feature flags, A/B testing, and experimentation. The analytics layer is newer but the experimentation platform is mature. Generous free tier with 1 million events/month. A strong choice if experimentation is as important to you as analytics.
|
||||
|
||||
### [Plausible Analytics](https://plausible.io/)
|
||||
|
||||
A lightweight, privacy-first [web analytics tool](/compare/plausible-alternative) that's the opposite of Mixpanel: simple, no cookies, GDPR-compliant by default. It won't replace Mixpanel's product analytics, but if you've realized you only need web analytics (not custom events and funnels), Plausible is a clean, affordable option at $9/month. Also available as open source for self-hosting.
|
||||
|
||||
### [Umami](https://umami.is/)
|
||||
|
||||
Another lightweight open-source [web analytics alternative](/compare/umami-alternative) focused on simplicity and privacy. Similar to Plausible but fully free and self-hostable. Great for developers who want basic analytics without the complexity. Like Plausible, it's a web analytics tool — not a product analytics replacement for Mixpanel.
|
||||
|
||||
## Migration Considerations
|
||||
|
||||
When switching from Mixpanel to any alternative, consider:
|
||||
|
||||
**Data Migration**: Most tools allow you to export Mixpanel data and import historical events. Check for migration guides and tools.
|
||||
**Data Migration**: Most tools allow you to export Mixpanel data and import historical events. Check for migration guides — we have a dedicated [migrate from Mixpanel](/guides/migrate-from-mixpanel) guide for OpenPanel.
|
||||
|
||||
**SDK Compatibility**: Review your current Mixpanel implementation and plan for code changes. Many alternatives offer similar APIs to ease transition.
|
||||
**SDK Compatibility**: Review your current Mixpanel implementation and plan for code changes. OpenPanel supports [Next.js](/docs/sdks/nextjs), [React](/docs/sdks/react), [Vue](/docs/sdks/vue), [Python](/docs/sdks/python), and [many more frameworks](/docs/sdks).
|
||||
|
||||
**Feature Parity**: List your must-have Mixpanel features and ensure your chosen alternative supports them.
|
||||
**Feature Parity**: List your must-have Mixpanel features and ensure your chosen alternative supports them. For a detailed feature-by-feature comparison, see our [OpenPanel vs Mixpanel](/compare/mixpanel-alternative) page.
|
||||
|
||||
**Team Training**: Budget time for your team to learn the new tool. Simpler alternatives like OpenPanel require less training than complex platforms like PostHog.
|
||||
|
||||
**Cost Analysis**: Calculate total cost including hosting, maintenance, and potential feature add-ons. Don't forget to factor in engineering time for self-hosted solutions.
|
||||
**Cost Analysis**: Calculate total cost including hosting, maintenance, and potential feature add-ons. Don't forget to factor in engineering time for [self-hosted solutions](/articles/self-hosted-web-analytics).
|
||||
|
||||
## Conclusion
|
||||
|
||||
The best Mixpanel alternative varies based on your priorities and product type:
|
||||
|
||||
- **[OpenPanel](https://openpanel.dev)** offers the best balance of features, affordability, and privacy for most teams
|
||||
- **PostHog** is ideal if you need an all-in-one platform with session replay and feature flags
|
||||
- **Heap** suits enterprises that value retroactive analysis and can afford premium pricing
|
||||
- **Amplitude** works for high-volume products that fit within the generous free tier
|
||||
- **[OpenPanel](/compare/mixpanel-alternative)** offers the best balance of features, affordability, and privacy for most teams
|
||||
- **[PostHog](/compare/posthog-alternative)** is ideal if you need an all-in-one platform with session replay and feature flags
|
||||
- **[Heap](/compare/heap-alternative)** suits enterprises that value retroactive analysis and can afford premium pricing
|
||||
- **[Amplitude](/compare/amplitude-alternative)** works for high-volume products that fit within the generous free tier
|
||||
- **Pendo** is perfect for teams that need analytics plus in-app guidance and user onboarding
|
||||
- **[Matomo](/compare/matomo-alternative)** is the go-to choice for GDPR compliance and regulated industries
|
||||
- **GA4** is unbeatable on price if you're already in the Google ecosystem
|
||||
|
||||
The key is understanding what you actually need versus what sounds impressive. Many teams pay for complex analytics platforms but only use basic features. Start with your real requirements, not aspirational ones, and you'll find the right Mixpanel alternative for your needs.
|
||||
|
||||
**Also consider:** If you're primarily tracking website traffic rather than product events, check out our guide to [open-source web analytics tools](/articles/open-source-web-analytics) for simpler alternatives like Plausible, Umami, and Matomo.
|
||||
**Also consider:** If you're primarily tracking website traffic rather than product events, check out our guide to [open-source web analytics tools](/articles/open-source-web-analytics) for simpler alternatives like [Plausible](/compare/plausible-alternative), [Umami](/compare/umami-alternative), and [Matomo](/compare/matomo-alternative).
|
||||
|
||||
Ready to try a simpler, more affordable approach to product analytics? [Start your free trial of OpenPanel](https://dashboard.openpanel.dev/onboarding) and see how easy analytics can be.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What is the best free Mixpanel alternative?">
|
||||
For most teams, **OpenPanel** is the best free Mixpanel alternative because you can [self-host it](/articles/how-to-self-host-openpanel) with no feature limitations. PostHog also offers a generous free tier of 1 million events per month on their cloud, and Amplitude gives you 10 million events free (though paid plans are opaque). If you just need web analytics without product analytics features, [Plausible](/compare/plausible-alternative) and Umami are excellent free open-source options.
|
||||
</FaqItem>
|
||||
<FaqItem question="What is the best open source Mixpanel alternative?">
|
||||
**OpenPanel** (AGPL-3.0) and **PostHog** (MIT) are the two leading [open source analytics](/articles/open-source-web-analytics) alternatives to Mixpanel. OpenPanel is lighter-weight and more affordable, while PostHog offers more features (session replay, feature flags) at the cost of complexity and higher resource requirements.
|
||||
</FaqItem>
|
||||
<FaqItem question="How much does Mixpanel cost in 2026?">
|
||||
Mixpanel offers a free plan with 20 million events per month (increased from 100k). Paid Growth plans start at $28/month for additional features like unlimited saved reports and advanced analytics. At scale, costs grow quickly — tracking 1 million events on the Growth plan costs approximately $779/month. See our full [Mixpanel pricing breakdown](/articles/mixpanel-pricing) for details.
|
||||
</FaqItem>
|
||||
<FaqItem question="Can I self-host a Mixpanel alternative?">
|
||||
Yes. OpenPanel and PostHog both offer full [self-hosted deployments](/docs/self-hosting/self-hosting). OpenPanel can run on a single VPS with minimal resources, while PostHog requires more infrastructure (16GB+ RAM). Self-hosting gives you complete data ownership, GDPR compliance by design, and eliminates recurring SaaS costs.
|
||||
</FaqItem>
|
||||
<FaqItem question="Which Mixpanel alternative is best for startups?">
|
||||
**OpenPanel** is the best choice for most startups because of its low pricing ($2.50/mo starting) and the option to self-host for free. PostHog's free tier (1M events) is also attractive for early-stage startups. Amplitude's 10M event free tier works well if you don't need self-hosting. Avoid Heap and Pendo for startups — their pricing starts in the thousands per year.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the easiest Mixpanel alternative to set up?">
|
||||
OpenPanel is the easiest to get started with — cloud setup takes under 5 minutes, and the SDK is lightweight. PostHog cloud is also quick to set up. Self-hosting adds complexity for both tools, but OpenPanel's single Docker Compose deployment is simpler than PostHog's multi-service architecture.
|
||||
</FaqItem>
|
||||
<FaqItem question="Do Mixpanel alternatives support mobile analytics?">
|
||||
Yes. OpenPanel has SDKs for [React Native](/docs/sdks/react-native), [Swift](/docs/sdks/swift) (iOS), and [Kotlin](/docs/sdks/kotlin) (Android). PostHog supports iOS, Android, React Native, and Flutter. Amplitude and Pendo also have strong mobile SDKs. Heap supports mobile via their autocapture SDK.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
332
apps/public/content/articles/mixpanel-pricing.mdx
Normal file
@@ -0,0 +1,332 @@
|
||||
---
|
||||
title: "Mixpanel Pricing 2026: Free Plan, Growth Costs & Full Breakdown"
|
||||
description: "Complete guide to Mixpanel pricing in 2026. Free plan (1M events), Growth plan ($0.00028/event), Enterprise, hidden add-on costs, and how it compares to OpenPanel."
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-12-08
|
||||
updated: 2026-02-13
|
||||
cover: /content/mixpanel-pricing-cover.jpg
|
||||
---
|
||||
|
||||
Mixpanel pricing starts with a free plan capped at **1 million monthly events**. The Growth plan charges **$0.00028 per event** ($0.28 per 1,000 events) after 1M free events, with costs reaching ~$2,520/month at 10 million events. Enterprise pricing requires contacting sales and typically starts around $25,000/year.
|
||||
|
||||
Here's the quick overview:
|
||||
|
||||
| Plan | Monthly Events | Price | Best For |
|
||||
|------|---------------|-------|----------|
|
||||
| **Free** | Up to 1M | $0 | Small projects, MVPs |
|
||||
| **Growth** | 1M free, up to 20M | From $0 (scales with usage) | Growing companies |
|
||||
| **Enterprise** | Unlimited | Custom (from ~$25K/year) | Large organizations |
|
||||
|
||||
Now let's break down exactly what you get at each tier, what the hidden costs are, and whether Mixpanel is worth the price for your use case.
|
||||
|
||||
## How Mixpanel Pricing Works
|
||||
|
||||
Mixpanel uses **event-based pricing**. You pay based on the number of events you track, not the number of users on your team or the number of projects you have.
|
||||
|
||||
An "event" is any user action you decide to track: a button click, a page view, a purchase, a sign-up. Each one counts as one event. If you track 10 different actions and have 10,000 monthly active users, you could easily be looking at millions of events per month.
|
||||
|
||||
This model means your costs scale directly with your product's usage. The more successful your product becomes, the more you pay.
|
||||
|
||||
## Mixpanel Free Plan
|
||||
|
||||
Does Mixpanel have a free tier? **Yes.** The Mixpanel free plan includes up to **1 million monthly events** with core analytics features. It's a decent starting point for small projects and early-stage startups.
|
||||
|
||||
Here's what's included in the free plan:
|
||||
|
||||
- **1M monthly events** — once you hit the cap, you'll need to upgrade
|
||||
- **Core analytics** — Insights, [Funnels](/features/funnels), Flows, and [Retention](/features/retention) reports
|
||||
- **5 saved reports per seat** — enough to start, but teams feel this limit quickly
|
||||
- **10K monthly session replays** — see exactly how users interact with your product
|
||||
- **30 Spark AI queries per month** — Mixpanel's AI-powered query builder
|
||||
- **Unlimited seats** — no per-user charges
|
||||
|
||||
However, the free plan has significant limitations:
|
||||
|
||||
- **No Group Analytics** — essential for B2B products that need account-level analysis
|
||||
- **No data export** — you can't pull data into a warehouse
|
||||
- **Limited behavioral cohorts** — advanced segmentation requires Growth
|
||||
- **No formulas or saved metrics** — basic reporting only
|
||||
- **No anomaly detection or root cause analysis**
|
||||
- **No multi-touch attribution**
|
||||
- **Limited custom properties and borrowed properties**
|
||||
- **5 monitoring alerts per project** (Growth gets unlimited)
|
||||
|
||||
The free plan works well for validating a product idea or running basic analytics on a small project. But once you need collaboration features, advanced analysis, or your events exceed 1 million per month, you'll need to upgrade.
|
||||
|
||||
## Mixpanel Growth Plan
|
||||
|
||||
The Growth plan is where most paying customers land. It includes the first **1 million events free** each month. After that, you pay approximately **$0.00028 per event** ($0.28 per 1,000 events), with volume discounts available at higher tiers.
|
||||
|
||||
Here's how Mixpanel Growth plan pricing scales with event volume:
|
||||
|
||||
| Monthly Events | Estimated Monthly Cost | Cost per 1K Events |
|
||||
|---------------|----------------------|-------------------|
|
||||
| 1M | $0 (free) | $0.00 |
|
||||
| 2M | ~$280 | ~$0.28 |
|
||||
| 5M | ~$1,120 | ~$0.28 |
|
||||
| 10M | ~$2,520 | ~$0.28 |
|
||||
| 20M | ~$5,320 | ~$0.28 |
|
||||
| 20M+ | Contact sales | Volume discounts |
|
||||
|
||||
*Prices are approximate based on Mixpanel's pricing calculator. Annual billing typically offers 10-15% discounts.*
|
||||
|
||||
The Growth plan adds several important features over Free:
|
||||
|
||||
- **Unlimited saved reports** — no more caps on collaborative analytics
|
||||
- **Full behavioral cohorts** — segment users based on actions
|
||||
- **20K monthly session replays** (customizable up to 500K)
|
||||
- **60 Spark AI queries per month** (double the free plan)
|
||||
- **Formulas & saved metrics** — build advanced calculations
|
||||
- **Multi-touch attribution** — understand which channels drive conversions
|
||||
- **Impact & statistical significance** — measure feature impact
|
||||
- **Anomaly detection & root cause analysis** — catch issues early
|
||||
- **Unlimited monitoring alerts**
|
||||
- **Custom properties and borrowed properties** — full flexibility
|
||||
|
||||
One thing to note: these prices are for core analytics only. Several features that many teams consider essential are **paid add-ons** on top of the Growth plan.
|
||||
|
||||
<Figure
|
||||
src="/content/mixpanel-pricing.png"
|
||||
caption="Mixpanel pricing showing the different plans and their prices"
|
||||
/>
|
||||
|
||||
## Free vs Growth Plan: What's the Difference?
|
||||
|
||||
If you're trying to decide whether to stay on Free or upgrade to Growth, here's a detailed breakdown:
|
||||
|
||||
| Feature | Free Plan | Growth Plan |
|
||||
|---------|-----------|-------------|
|
||||
| **Monthly events** | Up to 1M | First 1M free, up to 20M |
|
||||
| **Saved reports** | 5 per seat | Unlimited |
|
||||
| **Session replays** | 10K/month | 20K free (up to 500K) |
|
||||
| **Spark AI queries** | 30/month | 60/month |
|
||||
| **Behavioral cohorts** | Limited | Full access |
|
||||
| **Custom properties** | Limited | Full access |
|
||||
| **Formulas & saved metrics** | Limited | Full access |
|
||||
| **Impact & statistical significance** | No | Yes |
|
||||
| **Multi-touch attribution** | No | Yes |
|
||||
| **Monitoring alerts** | 5 per project | Unlimited |
|
||||
| **Anomaly detection** | No | Yes |
|
||||
| **Root cause analysis** | No | Yes |
|
||||
| **Cart analysis** | No | Yes |
|
||||
| **Campaign reporting** | No | Yes |
|
||||
| **Experiment reporting** | No | Add-on |
|
||||
| **Feature flags** | No | Add-on |
|
||||
| **Account-level analytics** | No | Add-on |
|
||||
| **Data pipelines** | No | Add-on |
|
||||
| **Metric Trees** | No | Add-on |
|
||||
| **Lookup tables** | Limited | Full access |
|
||||
| **Support** | Email (standard) | Email (24/5) |
|
||||
|
||||
The bottom line: Free works for basic analytics and small projects. Once you need advanced features like formulas, cohort analysis, unlimited saved reports, or more than 1M monthly events, you'll need Growth. And even on Growth, several important features are still add-ons that cost extra.
|
||||
|
||||
## Mixpanel Enterprise Plan
|
||||
|
||||
Enterprise pricing isn't published. You need to contact sales, and the final price depends on your event volume, feature requirements, and negotiation.
|
||||
|
||||
Based on publicly available data, **Mixpanel Enterprise plans typically start around $25,000-$30,000 per year** and can go well over $100,000/year for large-scale deployments.
|
||||
|
||||
Enterprise adds features that larger organizations need:
|
||||
|
||||
- **Unlimited monthly events** — custom volume with no caps
|
||||
- **Up to 1 trillion events** capacity
|
||||
- **SAML-based SSO & SCIM provisioning** — critical for security-conscious orgs
|
||||
- **Advanced data governance** — sensitive data classification & protection
|
||||
- **Compartmentalized data access** — granular permissions at the report level
|
||||
- **HIPAA compliance tools** — for healthcare companies
|
||||
- **Customizable data retention policy** — keep data longer than standard limits
|
||||
- **300 Spark AI queries per month**
|
||||
- **24/7 support** with faster response SLAs
|
||||
- **Slack shared channel support** (add-on)
|
||||
- **Dedicated account manager**
|
||||
- **Professional services** available
|
||||
- **Signal correlation analysis** — advanced analytics
|
||||
- **Cross-product analytics** — analyze across multiple products
|
||||
- **Data quality monitoring** and verified data
|
||||
- **Custom pricing & terms**
|
||||
|
||||
If you're a larger organization with compliance requirements, need SSO, or require specific security features, Enterprise is where you'll end up.
|
||||
|
||||
## The Add-Ons That Add Up
|
||||
|
||||
Here's where Mixpanel pricing gets tricky. Several features that you might consider core functionality are **paid add-ons**, even on Growth plans:
|
||||
|
||||
**Account-Level Behavioral Analytics (Group Analytics)** is the big one. If you're building a B2B product, you almost certainly need this. It lets you analyze data at the account or company level, not just individual users. Without it, you can't answer basic questions like "which companies are most engaged?" or "what's our retention by account?" This is a separate line item on your bill, even on Growth and Enterprise.
|
||||
|
||||
**Data Pipelines** is another common add-on. This lets you export your Mixpanel data to a data warehouse like BigQuery, Snowflake, or Redshift. If you need your analytics data in your warehouse for broader analysis, this cost adds up — estimates suggest $19,000+ annually for larger implementations.
|
||||
|
||||
**Session Replay** beyond the included free tier. Growth includes 20K monthly replays, but if you need more (up to 500K), that's extra.
|
||||
|
||||
**Warehouse Connectors** allow you to import data from your warehouse into Mixpanel. Separate pricing.
|
||||
|
||||
**Feature Flags** for controlling feature rollouts. Add-on on Growth.
|
||||
|
||||
**Experiment Reporting** for A/B testing analysis. Add-on on Growth.
|
||||
|
||||
**Metric Trees** for visualizing metric dependencies. Add-on on Growth.
|
||||
|
||||
When budgeting for Mixpanel, make sure you factor in which add-ons you'll actually need. The base plan price can be misleading if you end up needing two or three add-ons to do what you want.
|
||||
|
||||
## Mixpanel Startup Program
|
||||
|
||||
If you're an early-stage startup, Mixpanel offers a solid deal. Their startup program gives you access to a "Startup Plan" **free for one year**.
|
||||
|
||||
To qualify:
|
||||
- Founded less than **5 years ago**
|
||||
- Less than **$8 million** in total funding
|
||||
- Haven't redeemed similar offers before
|
||||
|
||||
The Startup Plan includes advanced features, Group Analytics, Data Pipelines, Warehouse Connectors, and Session Replay. You get up to 1 billion events over the year and 500,000 session replay recordings.
|
||||
|
||||
There's a catch: you need to start sending data within **90 days** of acceptance, or you get removed. And after the year is up, you'll need to move to a paid plan or downgrade to Free.
|
||||
|
||||
It's a good deal if you qualify, but plan ahead for what happens when that first year ends. Many startups get locked into Mixpanel during this free year and face a significant bill when it expires.
|
||||
|
||||
## When Mixpanel Gets Expensive
|
||||
|
||||
Mixpanel's event-based pricing means your costs are directly tied to your product's growth. Here's a realistic scenario:
|
||||
|
||||
Let's say you're tracking 15 different events per user. Your product has 50,000 monthly active users, each doing an average of 20 tracked actions per session, with 3 sessions per month. That's:
|
||||
|
||||
**15 × 50,000 × 20 × 3 = 45 million events per month**
|
||||
|
||||
At that volume, you're well past the Growth plan's 20M cap and into Enterprise territory — likely looking at $40,000+ per year minimum.
|
||||
|
||||
Now imagine you launch a marketing campaign that doubles your user base. Your analytics bill just doubled too.
|
||||
|
||||
Some teams respond by tracking fewer events or being selective about what they measure. That's not ideal. You want your analytics to grow with your product, not become a constraint on what you can learn about your users.
|
||||
|
||||
The other thing that catches people off guard is the cost of add-ons stacking up. Base plan + Group Analytics + Data Pipelines + extra Session Replays can easily 2-3x your expected bill.
|
||||
|
||||
## What Users Say About Mixpanel Pricing
|
||||
|
||||
Looking at reviews on G2, Capterra, and similar sites, pricing is one of the most common complaints about Mixpanel:
|
||||
|
||||
> "The jump from free to paid can be steep." Many users start on the generous free tier, get comfortable with the tool, and then face a significant cost when they outgrow it.
|
||||
|
||||
> "Gets expensive at scale." Companies with large user bases or those tracking many events find costs escalating quickly.
|
||||
|
||||
> "Add-ons feel like they should be included." Group Analytics in particular gets called out. For B2B products, it's essentially a required feature, but it's priced separately.
|
||||
|
||||
> "Pricing forced us to track less." Some users report deliberately limiting their tracking to stay within budget, which defeats the purpose of having comprehensive analytics.
|
||||
|
||||
To be fair, many users think Mixpanel provides good value, especially compared to building custom analytics infrastructure. The complaints tend to come from teams that have scaled beyond the free tier and are comparing costs to alternatives.
|
||||
|
||||
## Mixpanel vs OpenPanel: Pricing Comparison
|
||||
|
||||
Since you're reading this on the OpenPanel blog, let's be upfront about how we compare. We built [OpenPanel](/articles/introduction-to-openpanel) specifically as a more affordable alternative to Mixpanel.
|
||||
|
||||
Here's how pricing stacks up at different event volumes:
|
||||
|
||||
| Monthly Events | Mixpanel Growth | OpenPanel Cloud | Savings |
|
||||
|---------------|-----------------|-----------------|---------|
|
||||
| 100K | ~$0 (free tier)* | $20 | — |
|
||||
| 500K | ~$0 (free tier)* | $50 | — |
|
||||
| 1M | $0 (free tier) | $90 | — |
|
||||
| 2.5M | ~$420 | $180 | 57% cheaper |
|
||||
| 5M | ~$1,120 | $250 | 78% cheaper |
|
||||
| 10M | ~$2,520 | $350 | 86% cheaper |
|
||||
| 20M | ~$5,320 | $530 | 90% cheaper |
|
||||
| 30M | Contact sales | $680 | — |
|
||||
| 50M | Contact sales | $900 | — |
|
||||
|
||||
*Mixpanel's free tier covers up to 1M events but with limited features (5 saved reports per seat, no Group Analytics, no data export, limited cohorts).
|
||||
|
||||
At 10 million events, **OpenPanel is ~86% cheaper** than Mixpanel's Growth plan. But the pricing difference is only part of the story.
|
||||
|
||||
**Everything is included.** With [OpenPanel pricing](/pricing), all features are included at every tier. Unlimited websites, unlimited users, unlimited dashboards. No tiers within tiers, no add-ons, no "contact sales for this feature." Pick your event volume and that's your price.
|
||||
|
||||
**Self-hosting is free.** If you want maximum cost control, you can [self-host OpenPanel](/articles/how-to-self-host-openpanel) for free. Your only cost is infrastructure — a decent VPS can handle millions of events for $20-50/month.
|
||||
|
||||
**Privacy by default.** OpenPanel uses [cookieless tracking](/articles/cookieless-analytics) out of the box. No cookie consent banners needed. This means more accurate data because you're not losing users who decline cookies.
|
||||
|
||||
**No hidden costs.** Mixpanel's Growth plan price is just the starting point once you factor in Group Analytics, Data Pipelines, extra Session Replays, and other add-ons. OpenPanel's price is the full price.
|
||||
|
||||
Obviously, we're biased here. Mixpanel has been around longer, has more integrations, and has a larger team. If you need very specific capabilities that only Mixpanel offers, it might be worth the premium. But for most teams doing product analytics, OpenPanel covers the core use cases at a fraction of the cost.
|
||||
|
||||
See our full [Mixpanel vs OpenPanel comparison](/compare/mixpanel-alternative) for a feature-by-feature breakdown.
|
||||
|
||||
## Tips for Managing Mixpanel Costs
|
||||
|
||||
If you decide Mixpanel is the right tool for you, here are ways to keep costs under control:
|
||||
|
||||
**Be intentional about what you track.** Don't track everything just because you can. Define your key metrics and the events that feed into them. You can always add more tracking later.
|
||||
|
||||
**Use the startup program if you qualify.** That free year gives you runway to grow before analytics costs hit.
|
||||
|
||||
**Consider annual billing.** Mixpanel typically offers 10-15% discounts for annual commitments. If you're confident you'll stick with the tool, this is easy savings.
|
||||
|
||||
**Audit your tracking regularly.** Over time, teams accumulate tracking that's no longer used. Old features get deprecated, experiments end, but the events keep flowing. A quarterly audit can trim unnecessary events.
|
||||
|
||||
**Negotiate at renewal.** B2B SaaS pricing is often negotiable, especially at higher volumes. Don't just accept the renewal quote — ask what flexibility exists.
|
||||
|
||||
**Evaluate add-on necessity.** Before committing to Group Analytics or Data Pipelines, assess whether you truly need them or if there are workarounds.
|
||||
|
||||
## Making the Decision
|
||||
|
||||
Mixpanel is a solid product. The analytics are powerful, the UI is well-designed, and there's a reason it's one of the most popular tools in the category.
|
||||
|
||||
But pricing matters. If you're a growing startup watching your runway, a bootstrapped company keeping costs lean, or an enterprise trying to justify spend to finance — you need to understand the true cost of your analytics stack.
|
||||
|
||||
The questions to ask yourself:
|
||||
|
||||
1. How many events will you realistically track as you grow?
|
||||
2. Do you need Group Analytics for B2B analysis?
|
||||
3. Do you need data export to a warehouse?
|
||||
4. What's your total cost including add-ons, not just the base plan?
|
||||
5. What happens to your budget when your user base doubles?
|
||||
|
||||
If the answers make you nervous about Mixpanel's pricing trajectory, it's worth looking at alternatives before you're locked in.
|
||||
|
||||
[OpenPanel](/) offers a 30-day free trial with no credit card required. You can try it alongside Mixpanel and see which fits your needs and budget. And if you want maximum control over costs and data, [self-hosting](/docs/self-hosting/self-hosting) is always an option.
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How much does Mixpanel cost in 2026?">
|
||||
Mixpanel offers three plans in 2026: a Free plan with up to 1M monthly events and limited features, a Growth plan starting at $0 with 1M free events then $0.00028 per event ($0.28 per 1,000 events) scaling up to ~$5,320/month for 20M events, and an Enterprise plan with custom pricing typically starting around $25,000/year. Add-ons like Group Analytics, Data Pipelines, and extra Session Replays cost extra on top of the base plan.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Does Mixpanel have a free tier?">
|
||||
Yes. Mixpanel's free tier includes up to 1 million monthly events, 5 saved reports per seat, 10,000 monthly session replays, and 30 Spark AI queries per month. Core analytics features like Insights, Funnels, Flows, and Retention reports are included. However, you don't get Group Analytics, data export, advanced cohorts, formulas, multi-touch attribution, or anomaly detection on the free plan.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What are the Mixpanel free plan limits?">
|
||||
The Mixpanel free plan is limited to 1M monthly events, 5 saved reports per seat, 10K session replays per month, 30 Spark AI queries per month, and 5 monitoring alerts per project. Advanced features like behavioral cohorts, custom properties, formulas, and saved metrics are limited or unavailable. Group Analytics, Data Pipelines, and data export are not included.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How much does Mixpanel cost per event?">
|
||||
On the Mixpanel Growth plan, you pay approximately $0.00028 per event ($0.28 per 1,000 events) after the first 1 million free monthly events. Volume discounts may be available at higher tiers. Enterprise plans have custom per-event pricing negotiated directly with Mixpanel's sales team.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What counts as an event in Mixpanel?">
|
||||
An event is any user action you choose to track. This includes button clicks, page views, sign-ups, purchases, form submissions, feature usage, or any custom action you define. Each occurrence counts as one event toward your monthly total. Mixpanel also supports autocapture which automatically tracks events without manual instrumentation.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is Mixpanel worth the price?">
|
||||
It depends on your needs and budget. Mixpanel excels at deep product analytics — funnels, retention, cohort analysis, and experimentation. For well-funded companies that need its specific features and can afford the add-ons, it delivers value. For cost-conscious teams or those who need simpler analytics, the pricing can escalate quickly as you grow. Alternatives like OpenPanel offer similar core functionality at significantly lower prices with all features included.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Does Mixpanel charge for Group Analytics?">
|
||||
Yes. Group Analytics (account-level behavioral analytics) is a paid add-on on both Growth and Enterprise plans. This feature is essential for B2B products that need to analyze data at the company or account level rather than just individual users. The extra cost is not included in the base plan price shown on Mixpanel's pricing page.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How does Mixpanel pricing compare to OpenPanel?">
|
||||
OpenPanel is significantly cheaper at most event volumes and includes all features at every tier. At 10 million events, OpenPanel costs $350/month compared to Mixpanel's approximately $2,520/month — about 86% cheaper. OpenPanel also has no add-ons or hidden costs, and offers a free self-hosting option for maximum cost control.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can startups get Mixpanel for free?">
|
||||
Yes. Mixpanel's Startup Program offers eligible startups their first year free on the Startup Plan. To qualify, your company must be founded less than 5 years ago, have less than $8 million in total funding, and not have redeemed similar offers. The plan includes advanced features and up to 1 billion events over the year. You must start sending data within 90 days of acceptance.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What happens if I go over my Mixpanel event limit?">
|
||||
On the Growth plan, you'll be charged for additional events at your plan's overage rate. Mixpanel states they don't apply punitive overcharges — you pay the regular per-event rate. They also have a "forgiveness policy" for events tracked by mistake. On the free plan, exceeding the 1M event limit requires upgrading to a paid plan.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How many Spark AI queries does Mixpanel include?">
|
||||
The Free plan includes 30 Spark AI queries per month. The Growth plan doubles this to 60 queries per month. Enterprise plans include 300 Spark AI queries per month. Spark is Mixpanel's AI-powered query builder that lets you ask questions about your data in natural language.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What are Mixpanel's pricing alternatives for startups on a budget?">
|
||||
For startups looking for affordable product analytics, OpenPanel is a strong alternative starting at $2.50/month for 5,000 events and $90/month for 1M events with all features included. OpenPanel also offers free self-hosting. Other alternatives include PostHog (open source with a free tier) and Amplitude (limited free plan). Unlike Mixpanel, OpenPanel has no add-on costs for features like group analytics or data export.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: 9 best open source web analytics tools
|
||||
description: In an era where data drives decisions, what are your best options for web analytics?
|
||||
title: "9 Best Open Source Analytics Tools in 2026 (Compared)"
|
||||
description: "Comprehensive comparison of the best open source web analytics and product analytics tools in 2026. Self-hosted and cloud options with pricing, features, and honest reviews."
|
||||
date: 2024-11-10
|
||||
updated: 2026-02-13
|
||||
cover: /content/cover-best-web-analytics.jpg
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
@@ -9,16 +10,36 @@ team: OpenPanel Team
|
||||
import { Faqs, FaqItem } from '@/components/faq';
|
||||
import { Figure } from '@/components/figure'
|
||||
|
||||
In today's data-driven world, understanding your website's performance is crucial for making informed decisions. While Google Analytics dominates the market, many developers and businesses are turning to open source web analytics tools for better privacy, data ownership, and customization.
|
||||
The best open source web analytics tools in 2026 are **OpenPanel** (best overall for web + product analytics), **PostHog** (best all-in-one platform), **Plausible** (best for simplicity), and **Matomo** (best Google Analytics replacement). All offer self-hosting, privacy-first tracking, and GDPR compliance.
|
||||
|
||||
Open source web analytics solutions offer several advantages over proprietary alternatives:
|
||||
- **Complete data ownership** - Your analytics data stays on your servers
|
||||
- **Privacy compliance** - Many are GDPR/CCPA compliant by design
|
||||
- **No vendor lock-in** - You can modify and extend the code as needed
|
||||
- **Cost control** - Self-host for free or choose affordable hosted options
|
||||
- **Transparency** - Open source code means you know exactly what's being tracked
|
||||
Open source analytics tools give you what proprietary platforms like Google Analytics can't: **complete data ownership**, **no vendor lock-in**, **privacy compliance by design**, and **the ability to self-host for free**. Whether you need simple pageview tracking or advanced product analytics with funnels, retention, and session replay — there's an open source solution that fits. If you're specifically looking to replace GA4, see our [Google Analytics alternative comparison](/compare/google-analytics-alternative) and [migration guide](/guides/migrate-from-google-analytics).
|
||||
|
||||
In this comprehensive guide, we've evaluated 9 of the best open source web analytics tools available today. Each tool has been assessed based on features, pricing, ease of use, and community support. Whether you need simple pageview tracking or advanced product analytics, there's an open source solution that fits your needs.
|
||||
Here's a quick comparison of all 9 tools:
|
||||
|
||||
| Tool | Type | License | Self-Host | Cloud Pricing (from) | Best For |
|
||||
|------|------|---------|-----------|---------------------|----------|
|
||||
| [**OpenPanel**](#openpanel) | Web + Product | AGPL-3.0 | Free | $2.50/mo | Teams wanting Mixpanel-like analytics, open source |
|
||||
| [**PostHog**](#posthog) | Product + Web | MIT | Free | $0 + usage | Developers needing analytics + flags + experiments |
|
||||
| [**Plausible**](#plausible) | Web | AGPL-3.0 | Free | $9/mo | Simple, privacy-first pageview analytics |
|
||||
| [**Matomo**](#matomo) | Web | GPL-3.0 | Free | €29/mo | Google Analytics replacement with full feature set |
|
||||
| [**Fathom**](#fathom) | Web | Proprietary | License | $15/mo | Privacy-focused teams wanting zero maintenance |
|
||||
| [**Umami**](#umami) | Web | MIT | Free | $0 + usage | Developers wanting lightweight, self-hosted analytics |
|
||||
| [**Ackee**](#ackee) | Web | MIT | Free | None | Minimalist self-hosted tracking |
|
||||
| [**Pirsch**](#pirsch) | Web | AGPL-3.0 | License | $6/mo | Server-side analytics without JavaScript |
|
||||
| [**Swetrix**](#swetrix) | Web | AGPL-3.0 | Free | $5/mo | Cookieless analytics with performance monitoring |
|
||||
|
||||
Now let's dive deep into each tool.
|
||||
|
||||
## What to Look for in Open Source Analytics
|
||||
|
||||
Before picking a tool, consider these key factors:
|
||||
|
||||
- **Web analytics vs product analytics** — Do you just need pageviews and traffic sources, or do you need event tracking, funnels, retention, and user journeys?
|
||||
- **Self-hosted vs cloud** — Self-hosting gives you full data control and is often free, but requires server maintenance. Cloud hosting is easier but costs money.
|
||||
- **Privacy and compliance** — Does the tool track without cookies? Is it GDPR/CCPA compliant by design?
|
||||
- **Ease of setup** — Some tools take minutes to deploy, others need significant DevOps work.
|
||||
- **Community and maintenance** — Is the project actively maintained? How large is the community?
|
||||
- **Pricing model** — Event-based, pageview-based, or flat fee? How does cost scale with growth?
|
||||
|
||||
## OpenPanel
|
||||
|
||||
@@ -28,7 +49,7 @@ In this comprehensive guide, we've evaluated 9 of the best open source web analy
|
||||
/>
|
||||
|
||||
### Summary
|
||||
OpenPanel is an open-source, privacy-friendly web and product analytics platform. It combines the power of Mixpanel with the simplicity of Plausible. You get real-time event tracking, customizable charts, an overview dashboard with instant insights, individual user and session views, and plenty of SDKs. It uses cookie-free tracking and is GDPR compliant. You can self-host it for free or use the hosted Cloud service with a 30-day free trial.
|
||||
OpenPanel is an open source analytics platform that combines **web analytics and product analytics** in one tool. Think of it as Mixpanel meets Plausible — you get real-time [event tracking](/features/event-tracking), customizable charts, an overview dashboard with instant insights, individual user and session views, [funnels](/features/funnels), [retention analysis](/features/retention), and multiple SDKs. It uses cookieless tracking and is GDPR compliant by design. You can self-host for free or use the Cloud service with a 30-day free trial.
|
||||
|
||||
- Homepage: [**https://openpanel.dev**](https://openpanel.dev)
|
||||
- GitHub: [**https://github.com/Openpanel-dev/openpanel**](https://github.com/Openpanel-dev/openpanel)
|
||||
@@ -38,61 +59,65 @@ OpenPanel is an open-source, privacy-friendly web and product analytics platform
|
||||
|
||||
### Pricing
|
||||
|
||||
OpenPanel Cloud has a 30-day free trial with no credit card required. After the trial, you choose the tier matching your monthly event volume. If you outgrow your tier you simply upgrade. Self-hosting is free (you provide your own infrastructure).
|
||||
OpenPanel Cloud has a 30-day free trial with no credit card required. After the trial, you choose the tier matching your monthly event volume. Self-hosting is completely free — you provide your own infrastructure.
|
||||
|
||||
| Events per Month | Price (USD/month) | Annual Price (10× monthly) |
|
||||
| ---------------- | ----------------- | -------------------------- |
|
||||
| Up to 5 000 | \$2.50 | \$25 |
|
||||
| Up to 10 000 | \$5 | \$50 |
|
||||
| Up to 100 000 | \$20 | \$200 |
|
||||
| Up to 250 000 | \$30 | \$300 |
|
||||
| Up to 500 000 | \$50 | \$500 |
|
||||
| Up to 1 000 000 | \$90 | \$900 |
|
||||
| Up to 2 500 000 | \$180 | \$1 800 |
|
||||
| Up to 5 000 000 | \$250 | \$2 500 |
|
||||
| Up to 10 000 000 | \$400 | \$4 000 |
|
||||
| > 10 000 000 | Contact sales | Contact sales |
|
||||
| Events per Month | Price (USD/month) |
|
||||
| ---------------- | ----------------- |
|
||||
| Up to 5,000 | $2.50 |
|
||||
| Up to 10,000 | $5 |
|
||||
| Up to 100,000 | $20 |
|
||||
| Up to 250,000 | $30 |
|
||||
| Up to 500,000 | $50 |
|
||||
| Up to 1,000,000 | $90 |
|
||||
| Up to 2,500,000 | $180 |
|
||||
| Up to 5,000,000 | $250 |
|
||||
| Up to 10,000,000 | $350 |
|
||||
| Up to 20,000,000 | $530 |
|
||||
| Up to 30,000,000 | $680 |
|
||||
| Up to 40,000,000 | $800 |
|
||||
| Up to 50,000,000 | $900 |
|
||||
|
||||
> All Cloud plans include unlimited websites, unlimited dashboards, unlimited team members, email & Discord support, and 5 years of data retention.
|
||||
> All Cloud plans include unlimited websites, unlimited dashboards, unlimited team members, email & Discord support, and 5 years of data retention. No add-ons or hidden costs.
|
||||
|
||||
### Pros
|
||||
- Open-source and self-hostable so you control your data
|
||||
- Combines web and product analytics in one platform
|
||||
- Real-time tracking and instant insights, no delays
|
||||
- Cookie-free, GDPR/CCPA compliant by design
|
||||
- Generous free trial and usage-based pricing
|
||||
- Open source and self-hostable — you control your data completely
|
||||
- Combines web and product analytics in one platform (replaces both GA and Mixpanel)
|
||||
- Real-time tracking and instant insights, no delays
|
||||
- Cookieless, GDPR/CCPA compliant by design — no consent banners needed
|
||||
- Generous free trial and straightforward usage-based pricing
|
||||
- All features included at every tier, no add-ons
|
||||
|
||||
### Cons
|
||||
- Cloud costs grow as event volume increases
|
||||
- Self-hosting requires server setup and maintenance
|
||||
- Project is still early-stage with a smaller community
|
||||
- Lacks some advanced features like session replay or heatmaps
|
||||
- Cloud costs grow as event volume increases
|
||||
- Self-hosting requires server setup and maintenance
|
||||
- Project is newer with a smaller community than Matomo or PostHog
|
||||
- No built-in session replay or heatmaps (yet)
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How does OpenPanel pricing work after the free trial?">
|
||||
After your 30-day free trial, you pick the plan that covers your monthly events. If you exceed your tier's limit, you simply move up to the next tier or contact sales for a custom enterprise plan.
|
||||
After your 30-day free trial, you pick the plan that covers your monthly events. If you exceed your tier's limit, you simply move up to the next tier or contact sales for a custom enterprise plan. All features are included at every pricing level.
|
||||
</FaqItem>
|
||||
<FaqItem question="What tools can OpenPanel replace?">
|
||||
OpenPanel can stand in for Mixpanel, Amplitude and even Google Analytics by offering both product and web analytics in a single, privacy-friendly platform.
|
||||
OpenPanel can replace Mixpanel, Amplitude, and Google Analytics by offering both product and web analytics in a single, privacy-friendly platform. It tracks events, pageviews, user journeys, funnels, and retention — all without cookies.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's included in the open-source version of OpenPanel?">
|
||||
Everything—custom dashboards, charts, real-time views, user/session drill-downs and all SDKs—ships under an AGPL-3.0 licence with no feature gating.
|
||||
<FaqItem question="What's included in the open source version of OpenPanel?">
|
||||
Everything — custom dashboards, charts, real-time views, user/session drill-downs, funnels, retention, and all SDKs ship under an AGPL-3.0 license with no feature gating. The self-hosted version is identical to the cloud version.
|
||||
</FaqItem>
|
||||
<FaqItem question="What are the hosting requirements?">
|
||||
A mid-range VPS (≈ 4 vCPU / 8 GB RAM / SSD) running Docker Compose is sufficient for most projects; scale vertically as event volume grows.
|
||||
<FaqItem question="What are the hosting requirements for OpenPanel?">
|
||||
A mid-range VPS (approximately 4 vCPU, 8 GB RAM, SSD) running Docker Compose is sufficient for most projects. Scale vertically as event volume grows. A decent VPS costs $20-50/month and can handle millions of events.
|
||||
</FaqItem>
|
||||
<FaqItem question="Is there a hosted option?">
|
||||
Yes. OpenPanel Cloud starts with a 30-day free trial, then switches to usage-based tiers—e.g. \$20 for 100 k events or \$90 for 1 M events per month.
|
||||
<FaqItem question="Is there a hosted option for OpenPanel?">
|
||||
Yes. OpenPanel Cloud starts with a 30-day free trial (no credit card required), then switches to usage-based tiers — for example, $20/month for 100K events or $90/month for 1M events.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to OpenPanel
|
||||
**Plausible Analytics**
|
||||
- Open-source, privacy-focused web analytics with flat pricing
|
||||
- Lightweight, cookie-free tracking and simple dashboards
|
||||
- Predictable monthly cost starting at \$9 for 10 000 pageviews
|
||||
- Open source, privacy-focused web analytics with flat pricing
|
||||
- Lightweight, cookieless tracking and simple dashboards
|
||||
- Predictable monthly cost starting at $9 for 10,000 pageviews
|
||||
- Ideal if you need core web metrics without product analytics
|
||||
|
||||
## PostHog
|
||||
@@ -102,56 +127,63 @@ Yes. OpenPanel Cloud starts with a 30-day free trial, then switches to usage-bas
|
||||
caption="PostHog"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs PostHog](/compare/posthog-alternative)
|
||||
|
||||
### Summary
|
||||
PostHog is an open-source platform for product analytics. You can track events, watch session replays, roll out feature flags, run A/B tests, track errors, send surveys, and more—all in one place. You can self-host it or use their cloud service. All users get a generous free tier every month.
|
||||
PostHog is an open source platform for product analytics, session replay, feature flags, A/B testing, error tracking, and surveys — all in one place. It's developer-focused and offers both self-hosting and a generous cloud free tier. If you need a Swiss Army knife for product development, PostHog is a strong choice.
|
||||
|
||||
- Homepage: [**https://posthog.com**](https://posthog.com)
|
||||
- Github: [**https://github.com/PostHog/posthog**](https://github.com/PostHog/posthog)
|
||||
- GitHub: [**https://github.com/PostHog/posthog**](https://github.com/PostHog/posthog)
|
||||
- License: **MIT**
|
||||
- Rating: **4/5**
|
||||
|
||||
### Pricing
|
||||
|
||||
PostHog offers a generous free tier that includes 1 million events per month, along with 5,000 session recordings, 1 million feature flag requests, 100,000 error events, and 250 survey responses. This free tier covers 1 project with community support and 1 year of data retention.
|
||||
PostHog offers a generous free tier with 1 million analytics events per month, plus 5,000 session recordings, 1M feature flag requests, and more. Beyond the free limits, pricing uses volume-based step-down rates:
|
||||
|
||||
Beyond the free limits, PostHog operates on a pay-as-you-go model. Product analytics costs $0.00005 per event, session replay is $0.005 per recording, feature flags are $0.0001 per request, error tracking is $0.00037 per event, surveys are $0.20 per response, and data pipelines cost $0.000062 per event. The pay-as-you-go plan includes 6 projects, email support, and 7 years of data retention.
|
||||
| Monthly Events | Cost per Event | Example Monthly Cost |
|
||||
|---------------|---------------|---------------------|
|
||||
| First 1M | Free | $0 |
|
||||
| 1-2M | $0.00005 | ~$50 |
|
||||
| 2-15M | $0.0000343 | ~$396 (at 12M) |
|
||||
| 15-50M | $0.0000295 | Declining rate |
|
||||
| 50-100M | $0.0000218 | Declining rate |
|
||||
| 100-250M | $0.000015 | Declining rate |
|
||||
|
||||
| Plan | Price | Monthly Free Limits | Support | Projects | Retention |
|
||||
| -------------- | ---------------- | ----------------------------------------- | -------------- | -------- | --------- |
|
||||
| Free forever | $0 | See free tier details above | Community forum| 1 | 1 year |
|
||||
| Pay-as-you-go | $0 + usage rates | First free limits then usage-based billing| Email | 6 | 7 years |
|
||||
Anonymous events are 55-80% cheaper than identified events, which can significantly reduce costs.
|
||||
|
||||
### Pros
|
||||
- Open-source and self-host friendly so you keep full data control
|
||||
- All-in-one suite: analytics, replays, flags, experiments, errors, surveys
|
||||
- Generous monthly free tier for smaller sites or dev testing
|
||||
- Open source and self-host friendly — you keep full data control
|
||||
- All-in-one suite: analytics, replays, flags, experiments, errors, surveys
|
||||
- Generous monthly free tier for smaller projects
|
||||
- Strong developer community and active development
|
||||
|
||||
### Cons
|
||||
- Usage-based pricing can be hard to predict as you grow
|
||||
- Managing a self-hosted instance takes extra ops work and maintenance
|
||||
- Self-hosting vs cloud is not 1-to-1
|
||||
- Enterprise support and advanced add-ons can get costly
|
||||
- Usage-based pricing can be hard to predict as you grow
|
||||
- Managing a self-hosted instance takes significant ops work
|
||||
- Self-hosting vs cloud is not feature-equivalent
|
||||
- Complexity — the sheer number of features can be overwhelming
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How does PostHog pricing work after the free tier?">
|
||||
PostHog's pricing can be tricky since it's usage based. Depending on how many events you send you will pay X amount per event.
|
||||
PostHog uses volume-based step-down pricing. After the first 1M free events, you pay $0.00005 per event for the next million, then the rate drops as volume increases. A company sending 12M events monthly would pay approximately $393/month.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the difference between PostHog and OpenPanel?">
|
||||
OpenPanel is more affordable than PostHog once you exceed 1 million events, making it a cost-effective choice for growing projects. While PostHog offers a broader range of features, OpenPanel stands out for its simplicity in self-hosting and provides the same capabilities whether you use the cloud or self-hosted version. Both platforms serve as robust product and web analytics tools, but OpenPanel's pricing and deployment flexibility make it especially appealing for teams seeking value and ease of use.
|
||||
OpenPanel is more affordable than PostHog once you exceed 1 million events, making it a cost-effective choice for growing projects. While PostHog offers a broader range of features (flags, experiments, surveys, error tracking), OpenPanel focuses on web and product analytics with simpler self-hosting and identical cloud/self-hosted feature parity.
|
||||
</FaqItem>
|
||||
<FaqItem question="What tools can PostHog replace?">
|
||||
By bundling product analytics, web analytics, session replay, feature flags, experiments, error tracking and surveys, PostHog can replace Mixpanel, Amplitude, Google Analytics, Hotjar / FullStory and LaunchDarkly in one stack.
|
||||
By bundling product analytics, web analytics, session replay, feature flags, experiments, error tracking, and surveys, PostHog can replace Mixpanel, Amplitude, Google Analytics, Hotjar/FullStory, and LaunchDarkly in one stack.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's included in the open-source version?">
|
||||
PostHog Open Source (MIT) includes almost every feature but is limited to a single project per instance and comes without an uptime guarantee—ideal for hobby or proof-of-concept use.
|
||||
<FaqItem question="What's included in the open source version?">
|
||||
PostHog Open Source (MIT) includes almost every feature but is limited to a single project per instance and comes without an uptime guarantee — ideal for hobby or proof-of-concept use.
|
||||
</FaqItem>
|
||||
<FaqItem question="What are the self-hosting requirements?">
|
||||
The team recommends ≥ 4 vCPU, 16 GB RAM and 30 GB+ storage. Self-hosting works best up to ~300 k events / month; beyond that, PostHog Cloud scales for you.
|
||||
The team recommends at least 4 vCPU, 16 GB RAM and 30 GB+ storage. Self-hosting works best up to ~300K events/month; beyond that, PostHog Cloud handles scaling for you.
|
||||
</FaqItem>
|
||||
<FaqItem question="Is there a hosted option?">
|
||||
Yes. PostHog Cloud offers a free tier (1 M events, 5 k recordings, 1 M flag API requests) with US or EU data residency, then pay-as-you-go pricing after the limits.
|
||||
Yes. PostHog Cloud offers a free tier (1M events, 5K recordings, 1M flag API requests) with US or EU data residency, then pay-as-you-go pricing after the free limits.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
@@ -162,58 +194,63 @@ Yes. PostHog Cloud offers a free tier (1 M events, 5 k recordings, 1 M flag API
|
||||
caption="Plausible"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Plausible](/compare/plausible-alternative)
|
||||
|
||||
### Summary
|
||||
Plausible is an open-source, privacy-friendly web analytics tool. It tracks pageviews and custom events without cookies or personal data. You get real-time reports, goals, custom events, email or Slack reports, and GDPR/CCPA compliance. You can self-host for free or use their cloud service.
|
||||
Plausible is an open source, privacy-friendly web analytics tool built as a lightweight alternative to Google Analytics. It tracks pageviews and custom events without cookies or personal data. You get real-time reports, goals, custom events, email/Slack reports, and GDPR/CCPA compliance out of the box. It's the go-to choice if you want simple, clean analytics without complexity.
|
||||
|
||||
- Homepage: [**https://plausible.io**](https://plausible.io)
|
||||
- Github: [**https://github.com/plausible/analytics**](https://github.com/plausible/analytics)
|
||||
- GitHub: [**https://github.com/plausible/analytics**](https://github.com/plausible/analytics)
|
||||
- License: **AGPL v3**
|
||||
- Rating: **4/5**
|
||||
|
||||
### Pricing
|
||||
|
||||
Plausible offers traffic-based plans you can bill monthly or yearly (2 months free on annual). All plans include unlimited data retention, unlimited team members, Google Analytics import, and core features.
|
||||
Plausible uses traffic-based plans billed monthly or yearly (2 months free on annual). All plans include unlimited data retention, unlimited team members, Google Analytics import, and all core features.
|
||||
|
||||
| Plan | Pageviews per Month | Price (Monthly) | Price (Yearly) | Sites | Team Members | Data Retention |
|
||||
|-----------|---------------------|-----------------|----------------|-------|--------------|----------------|
|
||||
| Starter | Up to 10 000 | \$9 | \$90 | 1 | Unlimited | Unlimited |
|
||||
| Growth | Up to 100 000 | \$14 | \$140 | 3 | 3 | Unlimited |
|
||||
| Business | Up to 200 000 | \$19 | \$190 | 10 | 10 | Unlimited |
|
||||
| Enterprise| 200 001+ | Custom | Custom | 10+ | 10+ | Unlimited |
|
||||
| Plan | Pageviews per Month | Price (Monthly) | Price (Yearly) | Sites | Data Retention |
|
||||
|-----------|---------------------|-----------------|----------------|-------|----------------|
|
||||
| Starter | Up to 10,000 | $9 | $90 | 1 | Unlimited |
|
||||
| Growth | Up to 100,000 | $14 | $140 | 3 | Unlimited |
|
||||
| Business | Up to 200,000 | $19 | $190 | 10 | Unlimited |
|
||||
| Enterprise| 200,001+ | Custom | Custom | 10+ | Unlimited |
|
||||
|
||||
### Pros
|
||||
- Designed for privacy, with no cookies or personal data tracking
|
||||
- Simple, intuitive dashboard and easy setup
|
||||
- Open source, with free self-host option and transparent pricing
|
||||
- Designed for privacy — no cookies, no personal data tracking
|
||||
- Simple, intuitive dashboard that anyone can understand
|
||||
- Open source with free self-host option and transparent pricing
|
||||
- Very lightweight script (~1 KB) that doesn't slow your site
|
||||
|
||||
### Cons
|
||||
- No free tier beyond self-hosting
|
||||
- Costs can rise quickly as traffic grows
|
||||
- Fewer advanced features (like funnels or session replay) than some competitors
|
||||
- No free tier beyond self-hosting
|
||||
- Costs can rise quickly as traffic grows
|
||||
- No product analytics features (no [funnels](/features/funnels), retention, or user-level tracking)
|
||||
- No session replay or heatmaps
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What's the differnece between OpenPanel?">
|
||||
OpenPanel is similar to Plausible in that both offer privacy-friendly, open-source web analytics with easy self-hosting. However, OpenPanel goes further by including advanced product analytics features—such as event tracking, user journeys, and session replays—making it suitable for teams that need deeper insights into user behavior, not just website traffic. If you want a simple, privacy-focused analytics tool, Plausible is great; if you need more advanced product analytics in addition to web stats, OpenPanel is a better fit.
|
||||
<FaqItem question="What's the difference between Plausible and OpenPanel?">
|
||||
Both offer privacy-friendly, open source web analytics with easy self-hosting. However, OpenPanel goes further by including advanced product analytics features — such as event tracking, user journeys, funnels, and retention analysis — making it suitable for teams that need deeper insights into user behavior, not just website traffic. If you want the simplest possible analytics, Plausible is great; if you need product analytics too, OpenPanel is the better fit.
|
||||
</FaqItem>
|
||||
<FaqItem question="How does Plausible protect user privacy?">
|
||||
Plausible does not use cookies or collect personal data. All tracking is aggregated and anonymized, making it GDPR and CCPA compliant by design.
|
||||
Plausible does not use cookies or collect personal data. All tracking is aggregated and anonymized, making it GDPR and CCPA compliant by design. No consent banners needed.
|
||||
</FaqItem>
|
||||
<FaqItem question="What tools can Plausible replace?">
|
||||
Plausible is a privacy-first alternative to Google Analytics and other page-view focused trackers, giving you simple dashboards without cookies.
|
||||
Plausible is a privacy-first alternative to Google Analytics and other pageview-focused trackers, giving you simple dashboards without cookies or personal data collection.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's included in the open-source version?">
|
||||
Everything you see in Plausible Cloud—real-time dashboards, goals, custom events, unlimited retention and team members—is also in the self-hosted AGPL build.
|
||||
<FaqItem question="What's included in the open source version?">
|
||||
Everything you see in Plausible Cloud — real-time dashboards, goals, custom events, unlimited retention and team members — is also in the self-hosted AGPL build.
|
||||
</FaqItem>
|
||||
<FaqItem question="Is there a hosted option?">
|
||||
Yes. Plausible Cloud starts at \$9/month for 10 k page-views with simple, traffic-based pricing.
|
||||
Yes. Plausible Cloud starts at $9/month for 10K pageviews with simple, traffic-based pricing. A 30-day free trial is available.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to Plausible
|
||||
**Umami**
|
||||
- Umami is almost a 1-to-1 alternative to Plausible. A great web analytics tool
|
||||
- Umami is almost a 1-to-1 alternative to Plausible with an MIT license
|
||||
- Free cloud tier with 1M events/month
|
||||
|
||||
**OpenPanel**
|
||||
- Similar to both Umami and Plausible but also adds product analytics to the mix
|
||||
@@ -225,59 +262,62 @@ Yes. Plausible Cloud starts at \$9/month for 10 k page-views with simple, traffi
|
||||
caption="Matomo"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Matomo](/compare/matomo-alternative)
|
||||
|
||||
### Summary
|
||||
Matomo (formerly Piwik) is an open-source web analytics platform. You can track web and mobile visits, build charts, create dashboards, set goals, run A/B tests, record sessions, view heatmaps, and more. You own all your data and can choose to self-host for free or use Matomo Cloud for hosting and support.
|
||||
Matomo (formerly Piwik) is the most established open source web analytics platform. It's been around since 2007 and positions itself as the leading Google Analytics alternative. You can track web and mobile visits, build charts, create dashboards, set goals, run A/B tests, record sessions, view heatmaps, and more. You own all your data and can choose to self-host for free or use Matomo Cloud.
|
||||
|
||||
- Homepage: [**https://matomo.org**](https://matomo.org)
|
||||
- Github: [**https://github.com/matomo-org/matomo**](https://github.com/matomo-org/matomo)
|
||||
- GitHub: [**https://github.com/matomo-org/matomo**](https://github.com/matomo-org/matomo)
|
||||
- License: **GNU GPL v3**
|
||||
- Rating: **4/5**
|
||||
|
||||
### Pricing
|
||||
|
||||
Matomo offers two main options:
|
||||
| Plan | Price | Hits per Month | Support | Websites | Data Retention |
|
||||
| ------------ | -------------------------------- | --------------------- | ------------------------------------------ | -------- | -------------- |
|
||||
| On-Premise | €0 | Unlimited | Free community support or paid subscriptions | Unlimited| Forever |
|
||||
| Cloud (50K) | €29/month or €290/year | Up to 50,000 | Email support & Customer Success Manager | 30 | 24 months |
|
||||
|
||||
| Plan | Price | Hits per Month | Billing | Support | Websites | Team Members | Data Retention |
|
||||
| ------------ | -------------------------------- | --------------------- | --------------------------- | ------------------------------------------ | -------- | ------------ | -------------- |
|
||||
| On-Premise | €0 | Unlimited | Self-host (download) | Free community support or paid subscriptions | Unlimited| Unlimited | Forever |
|
||||
| Cloud (50 k) | €29/month or €290/year (2 months free) | Up to 50 000 | Monthly or annual | Email support & Customer Success Manager | 30 | 30 | 24 months |
|
||||
|
||||
- For more hits or custom allowances (100 k, 300 k, 1 M, etc.), contact sales for enterprise pricing.
|
||||
For higher volumes (100K, 300K, 1M+ hits), contact sales for enterprise pricing.
|
||||
|
||||
### Pros
|
||||
- You fully own your data and stay GDPR/CCPA compliant
|
||||
- Rich feature set: dashboards, A/B tests, heatmaps, session recordings, funnels
|
||||
- Free self-hosted option with no limits on hits or users
|
||||
- Most mature open source analytics platform (since 2007)
|
||||
- Full data ownership and GDPR/CCPA compliance
|
||||
- Rich feature set: dashboards, A/B tests, heatmaps, session recordings, funnels
|
||||
- Free self-hosted option with no limits on hits or users
|
||||
- Large plugin ecosystem
|
||||
|
||||
### Cons
|
||||
- Managing self-hosting requires server setup and maintenance
|
||||
- Cloud costs can rise sharply as traffic grows beyond base tiers
|
||||
- Many advanced plugins incur extra annual fees
|
||||
- Self-hosting requires server setup, maintenance, and PHP knowledge
|
||||
- Cloud costs rise sharply beyond the base 50K tier
|
||||
- Many advanced plugins (A/B testing, heatmaps, etc.) require paid subscriptions even on self-hosted
|
||||
- UI can feel dated compared to newer tools
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What is included in Matomo Cloud's €29/month plan?">
|
||||
For €29 per month (or €290 per year), you get up to 50 000 hits, 30 websites, 30 team members, 24 months of raw data retention, email support, and a Customer Success Manager.
|
||||
For €29 per month (or €290/year), you get up to 50,000 hits, 30 websites, 30 team members, 24 months of raw data retention, email support, and a Customer Success Manager.
|
||||
</FaqItem>
|
||||
<FaqItem question="What tools can Matomo replace?">
|
||||
Matomo positions itself as a GDPR-compliant alternative to Google Analytics 4.
|
||||
Matomo is designed as a direct GDPR-compliant replacement for Google Analytics 4. It covers web analytics, ecommerce tracking, and basic product analytics.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's included in the open-source version?">
|
||||
Self-hosted Matomo covers user & event tracking, ecommerce analytics, segmentation and customizable dashboards; premium add-ons (A/B testing, heatmaps, etc.) cost extra.
|
||||
<FaqItem question="What's included in the open source version?">
|
||||
Self-hosted Matomo covers user and event tracking, ecommerce analytics, segmentation, and customizable dashboards. However, premium add-ons (A/B testing, heatmaps, session recordings) cost extra.
|
||||
</FaqItem>
|
||||
<FaqItem question="What are the self-hosting requirements?">
|
||||
For ~100 k page-views / month: 2 CPU, 2 GB RAM and 50 GB SSD. Over 1 M page-views Matomo recommends separate app and DB servers.
|
||||
For ~100K pageviews/month: 2 CPU, 2 GB RAM and 50 GB SSD. Over 1M pageviews, Matomo recommends separate app and database servers.
|
||||
</FaqItem>
|
||||
<FaqItem question="Is there a hosted option?">
|
||||
Yes. Matomo Cloud charges by "hits per month" and unlocks all paid add-ons out-of-the-box.
|
||||
Yes. Matomo Cloud charges by "hits per month" and unlocks all paid add-ons out of the box. Plans start at €29/month for 50K hits.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to Matomo
|
||||
**Fathom Analytics**
|
||||
- Privacy-focused, simple analytics with no cookies or personal data
|
||||
- Flat monthly pricing (starts at $14/month) for unlimited pageviews
|
||||
- Flat monthly pricing (starts at $15/month) for unlimited sites
|
||||
- Easy setup with minimal interface for core metrics
|
||||
|
||||
## Fathom
|
||||
@@ -287,52 +327,53 @@ Yes. Matomo Cloud charges by "hits per month" and unlocks all paid add-ons out-o
|
||||
caption="Fathom"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Fathom](/compare/fathom-alternative)
|
||||
|
||||
### Summary
|
||||
Fathom is a simple, privacy-focused web analytics tool. It tracks pageviews and events without cookies or personal data. You get real-time reports, unlimited data retention, unlimited sites, and simple dashboards. You can use Fathom's hosted service with a free trial or self-host with their Docker image (self-hosting requires a license).
|
||||
Fathom is a privacy-focused web analytics tool that tracks pageviews and events without cookies or personal data. You get real-time reports, unlimited data retention, unlimited sites, and simple dashboards. While Fathom isn't truly open source (it's proprietary with a self-host license available), it's included here because it's a popular alternative in the privacy-first analytics space.
|
||||
|
||||
- Homepage: [**https://usefathom.com**](https://usefathom.com)
|
||||
- Github: **Unsure** (no public repo for core analytics)
|
||||
- GitHub: **No public repository** (proprietary)
|
||||
- License: **Proprietary** (self-host license available)
|
||||
- Rating: **4/5**
|
||||
|
||||
### Pricing
|
||||
|
||||
Fathom bills by monthly data points (pageviews + events). You pay for the tier you need and can move up at any time. All plans include unlimited sites, data retention, email reports, exports, and support.
|
||||
Fathom bills by monthly data points (pageviews + events). All plans include unlimited sites, data retention, email reports, exports, and support.
|
||||
|
||||
| Plan | Data Points / Month | Price (Monthly) | Price (Yearly, 17% off) |
|
||||
| ---------------------- | ------------------- | --------------- | ----------------------- |
|
||||
| Starter | Up to 100 000 | \$15 | \$150/yr |
|
||||
| Growth | Up to 200 000 | \$25 | \$250/yr |
|
||||
| Business | Up to 500 000 | \$45 | \$450/yr |
|
||||
| Scale | Up to 1 000 000 | \$60 | \$600/yr |
|
||||
| Scale+ | Up to 2 000 000 | \$100 | \$1 000/yr |
|
||||
| Enterprise Small | Up to 5 000 000 | \$140 | \$1 400/yr |
|
||||
| Enterprise Medium | Up to 10 000 000 | \$200 | \$2 000/yr |
|
||||
| Enterprise Large | Up to 15 000 000 | \$290 | \$2 900/yr |
|
||||
| Enterprise X-Large | Up to 20 000 000 | \$380 | \$3 800/yr |
|
||||
| Enterprise Custom¹ | Over 25 000 000 | Contact sales | Contact sales |
|
||||
|
||||
¹ For volumes above 25 million data points, contact sales.
|
||||
| Starter | Up to 100,000 | $15 | $150/yr |
|
||||
| Growth | Up to 200,000 | $25 | $250/yr |
|
||||
| Business | Up to 500,000 | $45 | $450/yr |
|
||||
| Scale | Up to 1,000,000 | $60 | $600/yr |
|
||||
| Scale+ | Up to 2,000,000 | $100 | $1,000/yr |
|
||||
| Enterprise Small | Up to 5,000,000 | $140 | $1,400/yr |
|
||||
| Enterprise Medium | Up to 10,000,000 | $200 | $2,000/yr |
|
||||
| Enterprise Large | Up to 15,000,000 | $290 | $2,900/yr |
|
||||
| Enterprise X-Large | Up to 20,000,000 | $380 | $3,800/yr |
|
||||
| Enterprise Custom | Over 25,000,000 | Contact sales | Contact sales |
|
||||
|
||||
### Pros
|
||||
- No cookies or personal data collection by design
|
||||
- Real-time, simple dashboard with core metrics only
|
||||
- Unlimited sites and data retention on every plan
|
||||
- Self-host option lets you run it on your own infrastructure
|
||||
- No cookies or personal data collection by design
|
||||
- Real-time, simple dashboard with core metrics
|
||||
- Unlimited sites and data retention on every plan
|
||||
- EU-isolation option available for GDPR compliance
|
||||
|
||||
### Cons
|
||||
- Proprietary platform (core code is not open source)
|
||||
- Costs rise linearly as traffic grows
|
||||
- Lacks advanced features like funnels or session replay
|
||||
- **Not open source** — proprietary platform with no public code
|
||||
- Costs rise linearly as traffic grows
|
||||
- No product analytics features (no funnels, retention, or user tracking)
|
||||
- No session replay or heatmaps
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How does Fathom pricing work after the free trial?">
|
||||
Fathom charges by data-point tiers. You start on the plan matching your monthly pageviews and events, and you move up if you exceed your quota at month's end.
|
||||
Fathom charges by data-point tiers. You start on the plan matching your monthly pageviews and events, and you move up if you exceed your quota.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the difference between Fathom and OpenPanel?">
|
||||
Fathom is a hosted, proprietary analytics service with simple, tiered pricing. OpenPanel is open source and privacy friendly, offering both web and product analytics, real-time views, individual session tracking, charts, cookie-free GDPR compliance, and multiple SDKs you can self-host for free.
|
||||
Fathom is a hosted, proprietary analytics service with simple, tiered pricing focused on web analytics only. OpenPanel is open source and privacy-friendly, offering both web and product analytics, real-time views, individual [session tracking](/features/session-tracking), funnels, retention, charts, cookieless GDPR compliance, and multiple SDKs you can self-host for free.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
@@ -350,8 +391,10 @@ Fathom is a hosted, proprietary analytics service with simple, tiered pricing. O
|
||||
caption="Umami"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Umami](/compare/umami-alternative)
|
||||
|
||||
### Summary
|
||||
Umami is an open-source, privacy-friendly web analytics tool. It tracks pageviews and basic events without cookies or personal data. The lightweight script (~2 KB) loads fast and is GDPR/CCPA compliant by default. You can self-host it for free or use Umami Cloud with usage-based pricing.
|
||||
Umami is an open source, privacy-friendly web analytics tool. It tracks pageviews and basic events without cookies or personal data. The lightweight script (~2 KB) loads fast and is GDPR/CCPA compliant by default. It's one of the simplest open source analytics tools to self-host, making it popular with developers who want a quick Google Analytics replacement.
|
||||
|
||||
- Homepage: [**https://umami.is**](https://umami.is)
|
||||
- GitHub: [**https://github.com/umami-software/umami**](https://github.com/umami-software/umami)
|
||||
@@ -362,22 +405,24 @@ Umami is an open-source, privacy-friendly web analytics tool. It tracks pageview
|
||||
|
||||
Self-hosted Umami is free with unlimited sites and events, managed by you with community support.
|
||||
|
||||
Umami Cloud is free for the first 1 million events per month. Beyond that, you pay $0.00002 per additional event. You get unlimited websites, unlimited team members, 5 years of data retention, and email support.
|
||||
Umami Cloud is free for the first 1 million events per month. Beyond that, you pay $0.00002 per additional event.
|
||||
|
||||
| Plan | Price | Monthly Free Limits | Support | Retention |
|
||||
| ----------- | -------------------------------- | ------------------- | ------------- | ---------- |
|
||||
| Self-host | $0 | Unlimited events & sites | Community forum | Unlimited |
|
||||
| Cloud | $0 + $0.00002 per event over 1 M | 1 000 000 events | Email support | 5 years |
|
||||
| Cloud | $0 + $0.00002 per event over 1M | 1,000,000 events | Email support | 5 years |
|
||||
|
||||
### Pros
|
||||
- Fully open-source and self-hosted option gives you data control
|
||||
- Privacy-first design with no cookies or personal data collection
|
||||
- Very lightweight tracking script that won't slow your site
|
||||
- Fully open source (MIT) and self-hosted option gives you complete data control
|
||||
- Privacy-first design with no cookies or personal data collection
|
||||
- Very lightweight tracking script that won't slow your site
|
||||
- Simple to self-host with Docker Compose
|
||||
|
||||
### Cons
|
||||
- Limited to basic metrics; no session replay, funnels, or heatmaps
|
||||
- Running your own server means more maintenance and updates
|
||||
- Cloud costs can add up if you exceed free event limits significantly
|
||||
- Limited to basic web metrics; no product analytics, session replay, or heatmaps
|
||||
- Running your own server means maintenance and updates
|
||||
- Cloud costs can add up at high event volumes
|
||||
- Fewer advanced features compared to OpenPanel or PostHog
|
||||
|
||||
### FAQ
|
||||
|
||||
@@ -386,25 +431,25 @@ Umami Cloud is free for the first 1 million events per month. Beyond that, you p
|
||||
After the first 1 million events each month, Umami Cloud charges $0.00002 for each additional event. There are no per-site or per-user fees.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the difference between Umami and OpenPanel?">
|
||||
Umami provides open-source, cookie-free web analytics focused on pageviews and basic events. OpenPanel is also open-source and privacy-friendly but adds product analytics, real-time dashboards, individual session and user tracking, event visualization charts, and multiple SDKs—all cookie-free and GDPR compliant.
|
||||
Umami provides open source, cookieless web analytics focused on pageviews and basic events. OpenPanel is also open source and privacy-friendly but adds product analytics, real-time dashboards, individual session and user tracking, funnels, retention analysis, event visualization charts, and multiple SDKs — all cookieless and GDPR compliant.
|
||||
</FaqItem>
|
||||
<FaqItem question="What tools can Umami replace?">
|
||||
Primarily Google Analytics or any lightweight page-view tracker that relies on cookies.
|
||||
Umami is primarily a Google Analytics replacement for teams that want cookie-free, privacy-first pageview tracking without the complexity.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's included in the open-source version?">
|
||||
There's no feature gap—funnels, user paths, retention and custom events are all in the MIT-licensed build.
|
||||
<FaqItem question="What's included in the open source version?">
|
||||
There's no feature gap — funnels, user paths, retention, and custom events are all in the MIT-licensed build.
|
||||
</FaqItem>
|
||||
<FaqItem question="What are the self-hosting requirements?">
|
||||
A server with Node 18+ and either MySQL 8+ or PostgreSQL 12+; official Docker Compose templates make deployment straightforward.
|
||||
A server with Node 18+ and either MySQL 8+ or PostgreSQL 12+. Official Docker Compose templates make deployment straightforward.
|
||||
</FaqItem>
|
||||
<FaqItem question="Is there a hosted option?">
|
||||
Yes. Umami Cloud lets you track up to 100 k events each month for free, then bills \$0.00002 per event thereafter (e.g. \$20 for 1 M events).
|
||||
Yes. Umami Cloud lets you track up to 1M events each month for free, then bills $0.00002 per event thereafter.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to Umami
|
||||
**GoatCounter**
|
||||
- Open-source, privacy-focused analytics with simple pageview and event tracking
|
||||
- Open source, privacy-focused analytics with simple pageview and event tracking
|
||||
- Self-host for free or use hosted plans starting at €5/month
|
||||
- Lightweight script, no cookies required, GDPR compliant
|
||||
- Very simple setup and predictable flat pricing
|
||||
@@ -416,46 +461,49 @@ Yes. Umami Cloud lets you track up to 100 k events each month for free, then bil
|
||||
caption="Ackee"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Ackee](/compare/ackee-alternative)
|
||||
|
||||
### Summary
|
||||
Ackee is an open-source, self-hosted web analytics tool that focuses on privacy and simplicity. It runs on your own server with Node.js and MongoDB, tracks pageviews and custom events without cookies or personal data, and presents stats in a minimal interface. It uses a GraphQL API and keeps all tracked data anonymized by default.
|
||||
Ackee is an open source, self-hosted web analytics tool that focuses on privacy and minimalism. It runs on your own server with Node.js and MongoDB, tracks pageviews and custom events without cookies or personal data, and presents stats in a minimal interface. It uses a GraphQL API and keeps all tracked data anonymized by default.
|
||||
|
||||
- Homepage: [**https://ackee.electerious.com**](https://ackee.electerious.com)
|
||||
- Github: [**https://github.com/electerious/Ackee**](https://github.com/electerious/Ackee)
|
||||
- GitHub: [**https://github.com/electerious/Ackee**](https://github.com/electerious/Ackee)
|
||||
- License: **MIT**
|
||||
- Rating: **4/5**
|
||||
|
||||
### Pricing
|
||||
|
||||
Ackee itself is completely free to self-host. You download the code, run it on your server, and there are no limits on sites, pageviews, team members, or data retention.
|
||||
Ackee is completely free to self-host. You download the code, run it on your server, and there are no limits on sites, pageviews, team members, or data retention.
|
||||
|
||||
> _Note: There is no official Ackee-hosted service. Some third-party providers (for example Elestio) offer managed Ackee hosting on a credits-or-hourly basis, but pricing and terms vary by provider._
|
||||
> _Note: There is no official Ackee-hosted service. Some third-party providers (like Elestio) offer managed Ackee hosting, but pricing varies by provider._
|
||||
|
||||
### Pros
|
||||
- Fully open-source and free to self-host so you control all your data
|
||||
- Privacy-first design: no cookies, anonymized tracking, GDPR/CCPA compliant
|
||||
- Lightweight and minimal UI with fast load times
|
||||
- GraphQL API allows custom integrations and flexible querying
|
||||
- Fully open source and free to self-host — you control all your data
|
||||
- Privacy-first design: no cookies, anonymized tracking, GDPR/CCPA compliant
|
||||
- Lightweight and minimal UI with fast load times
|
||||
- GraphQL API allows custom integrations and flexible querying
|
||||
|
||||
### Cons
|
||||
- Limited to basic metrics (no funnels, session replay, or heatmaps)
|
||||
- Requires server setup, maintenance, and security updates
|
||||
- No official hosted offering—managed hosting depends on third parties
|
||||
- Minimal feature set may not suit advanced analytics needs
|
||||
- Limited to basic metrics (no funnels, session replay, or heatmaps)
|
||||
- Requires server setup, maintenance, and security updates
|
||||
- No official hosted offering — managed hosting depends on third parties
|
||||
- Minimal feature set won't suit teams needing advanced analytics
|
||||
- Requires MongoDB, which adds complexity
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How does Ackee pricing work after the free tier?">
|
||||
Ackee is free forever if you self-host. There is no paid tier or usage fees for the core software.
|
||||
<FaqItem question="How does Ackee pricing work?">
|
||||
Ackee is free forever if you self-host. There is no paid tier or usage fees for the core software. Your only costs are the server infrastructure you run it on.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the difference between Ackee and OpenPanel?">
|
||||
Ackee is a self-hosted, minimal web analytics tool that tracks pageviews and basic events without cookies. OpenPanel is also open-source and privacy-friendly but adds product analytics, real-time dashboards, individual session and user tracking, event visualization charts, and multiple SDKs you can use via cloud or self-host.
|
||||
Ackee is a self-hosted, minimal web analytics tool that tracks pageviews and basic events without cookies. OpenPanel is also open source and privacy-friendly but adds product analytics, real-time dashboards, individual session and user tracking, funnels, retention, event visualization charts, and multiple SDKs you can use via cloud or self-host.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to Ackee
|
||||
**GoatCounter**
|
||||
- Open-source and privacy-focused, with GDPR-compliant, cookie-free tracking
|
||||
- Open source and privacy-focused, with GDPR-compliant, cookieless tracking
|
||||
- Self-host free or use hosted plans starting at €5/month
|
||||
- Simple pageview and basic event metrics, predictable flat pricing
|
||||
|
||||
@@ -466,8 +514,10 @@ Ackee is a self-hosted, minimal web analytics tool that tracks pageviews and bas
|
||||
caption="Pirsch"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Pirsch](/compare/pirsch-analytics-alternative)
|
||||
|
||||
### Summary
|
||||
Pirsch is a drop-in, server-side, no-cookie, privacy-focused web analytics solution built in Go. It generates anonymized visitor fingerprints, works even with ad blockers, and is GDPR, CCPA, and PECR compliant. You can use the hosted SaaS offering or self-host under an enterprise license.
|
||||
Pirsch is a server-side, cookieless, privacy-focused web analytics solution built in Go. It generates anonymized visitor fingerprints, works even with ad blockers, and is GDPR, CCPA, and PECR compliant. The server-side approach means the tracking isn't affected by client-side blockers — a unique advantage.
|
||||
|
||||
- Homepage: [**https://pirsch.io**](https://pirsch.io)
|
||||
- GitHub: [**https://github.com/pirsch-analytics/pirsch**](https://github.com/pirsch-analytics/pirsch)
|
||||
@@ -478,44 +528,42 @@ Pirsch is a drop-in, server-side, no-cookie, privacy-focused web analytics solut
|
||||
|
||||
Pirsch offers a 30-day free trial with no credit card required. After that, plans are usage-based on monthly pageviews:
|
||||
|
||||
| Plan | Monthly Price | Annual Price (2 mo. free) | Pageviews Included | Websites | Members | Data Retention |
|
||||
|-------------|--------------------|---------------------------|--------------------|--------------------|-------------------|-------------------|
|
||||
| Standard | \$6 | \$60 | Up to 10 000 | Up to 50 | Unlimited | Unlimited |
|
||||
| Plus (Best Value) | \$12 | \$120 | Custom¹ | Unlimited | Unlimited | Unlimited |
|
||||
| Enterprise | Custom | Custom | Custom | Custom | Custom | Custom |
|
||||
| Plan | Monthly Price | Annual Price | Pageviews Included | Websites |
|
||||
|-----------------|---------------|--------------|--------------------|-----------|
|
||||
| Standard | $6 | $60 | Up to 10,000 | Up to 50 |
|
||||
| Plus | $12 | $120 | Custom | Unlimited |
|
||||
| Enterprise | Custom | Custom | Custom | Custom |
|
||||
|
||||
¹ Plus plan adds funnels, A/B testing, custom domains/themes, white-labeling, priority support, and event goals.
|
||||
|
||||
> _You pay per pageview tier; if you exceed your included pageviews, you move up to the next tier or contact sales for enterprise licensing._
|
||||
Plus plan adds funnels, A/B testing, custom domains/themes, white-labeling, priority support, and event goals.
|
||||
|
||||
### Pros
|
||||
- Fully privacy-focused with no cookies and anonymized data by default
|
||||
- Server-side tracking works around ad blockers and gives full data ownership
|
||||
- Lightweight Go library and multiple SDKs (JS, PHP, Laravel, etc.)
|
||||
- Hosted in the EU on German servers (Hetzner)
|
||||
- Server-side tracking works around ad blockers — get more complete data
|
||||
- Privacy-focused with no cookies and anonymized data by default
|
||||
- Lightweight Go library and multiple SDKs (JS, PHP, Laravel, etc.)
|
||||
- Hosted in the EU on German servers (Hetzner)
|
||||
|
||||
### Cons
|
||||
- Core self-host requires an enterprise license and setup support
|
||||
- Usage tiers can get costly as traffic grows beyond small sites
|
||||
- Hosted plans have feature gaps vs. self-host (e.g., custom integrations)
|
||||
- No built-in session replay or heatmaps
|
||||
- Self-hosting requires an enterprise license (not free)
|
||||
- Usage tiers can get costly as traffic grows
|
||||
- No session replay or heatmaps
|
||||
- Smaller community compared to Plausible or Matomo
|
||||
|
||||
### FAQ
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How does Pirsch pricing work after the free trial?">
|
||||
After 30 days, you choose the tier matching your monthly pageviews. If you go over your included pageviews, you upgrade to the next tier or contact sales for enterprise options.
|
||||
After 30 days, you choose the tier matching your monthly pageviews. If you exceed your included pageviews, you upgrade to the next tier or contact sales for enterprise options.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the difference between Pirsch and OpenPanel?">
|
||||
Pirsch is a hosted or enterprise-licensed Go-based analytics service focused on server-side tracking and EU hosting. OpenPanel is fully open-source, privacy-friendly, and cookie-free, offering both web and product analytics, real-time dashboards, individual session/user tracking, charts, GDPR compliance, and multiple SDKs you can self-host for free.
|
||||
Pirsch is a server-side Go-based analytics service focused on ad-blocker-resistant tracking and EU hosting. OpenPanel is fully open source, privacy-friendly, and cookieless, offering both web and product analytics, real-time dashboards, individual session/user tracking, funnels, retention, and multiple SDKs you can self-host for free.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to Pirsch
|
||||
**Plausible Analytics**
|
||||
- Open-source, cookie-free web analytics with a flat pricing model
|
||||
- Open source, cookieless web analytics with a flat pricing model
|
||||
- Simple dashboard, real-time data, and GDPR/CCPA compliance
|
||||
- Self-host for free or use hosted plans starting at \$9/month for 10 000 pageviews
|
||||
- Self-host for free or use hosted plans starting at $9/month for 10,000 pageviews
|
||||
|
||||
|
||||
## Swetrix
|
||||
@@ -526,7 +574,7 @@ Pirsch is a hosted or enterprise-licensed Go-based analytics service focused on
|
||||
/>
|
||||
|
||||
### Summary
|
||||
Swetrix is an open-source, cookieless web analytics platform that focuses on privacy and ease of use. You can track pageviews, custom events, user flows, performance metrics, and more—all without showing cookie banners. You can self-host for free or use the hosted service with a 14-day trial.
|
||||
Swetrix is an open source, cookieless web analytics platform that focuses on privacy and ease of use. You can track pageviews, custom events, user flows, performance metrics, and more — all without cookie banners. It also includes web performance monitoring, which most other tools in this list don't offer.
|
||||
|
||||
- Homepage: [**https://swetrix.com**](https://swetrix.com)
|
||||
- GitHub: [**https://github.com/Swetrix/swetrix**](https://github.com/Swetrix/swetrix)
|
||||
@@ -535,31 +583,31 @@ Swetrix is an open-source, cookieless web analytics platform that focuses on pri
|
||||
|
||||
### Pricing
|
||||
|
||||
Swetrix offers a 14-day free trial with no credit card required. After that, you pick a plan based on monthly events. If you need more than 10 million events, you contact sales.
|
||||
Swetrix offers a 14-day free trial with no credit card required. After that, you pick a plan based on monthly events:
|
||||
|
||||
| Plan | Events per Month | Price (USD/month) |
|
||||
| ------------------ | ---------------- | ----------------- |
|
||||
| Basic | Up to 10 000 | \$5 |
|
||||
| Growth | Up to 100 000 | \$15 |
|
||||
| Pro | Up to 200 000 | \$25 |
|
||||
| Business | Up to 500 000 | \$45 |
|
||||
| Premium | Up to 1 000 000 | \$59 |
|
||||
| Enterprise Small | Up to 2 000 000 | \$84 |
|
||||
| Enterprise Medium | Up to 5 000 000 | \$110 |
|
||||
| Enterprise Large | Up to 10 000 000 | \$150 |
|
||||
| Enterprise X-Large | Over 10 000 000 | Contact sales |
|
||||
| Basic | Up to 10,000 | $5 |
|
||||
| Growth | Up to 100,000 | $15 |
|
||||
| Pro | Up to 200,000 | $25 |
|
||||
| Business | Up to 500,000 | $45 |
|
||||
| Premium | Up to 1,000,000 | $59 |
|
||||
| Enterprise Small | Up to 2,000,000 | $84 |
|
||||
| Enterprise Medium | Up to 5,000,000 | $110 |
|
||||
| Enterprise Large | Up to 10,000,000 | $150 |
|
||||
| Enterprise X-Large | Over 10,000,000 | Contact sales |
|
||||
|
||||
### Pros
|
||||
- Fully open source and self-hostable so you keep full control of your data
|
||||
- Cookie-free tracking that is GDPR/CCPA compliant by design
|
||||
- Clean, simple dashboard with user flows, alerts, exports, and performance monitoring
|
||||
- No limits on sites or data exports in any plan
|
||||
- Fully open source and self-hostable for complete data control
|
||||
- Cookieless tracking that is GDPR/CCPA compliant by design
|
||||
- Includes web performance monitoring (page load times, Core Web Vitals)
|
||||
- Clean, simple dashboard with user flows, alerts, and exports
|
||||
|
||||
### Cons
|
||||
- Hosted plans use usage-based tiers, so costs rise with traffic
|
||||
- Self-hosting requires setup, maintenance, and hosting infrastructure
|
||||
- Lacks advanced features like session replay or heatmaps
|
||||
- Smaller community compared to older analytics platforms
|
||||
- Hosted plans use usage-based tiers, so costs rise with traffic
|
||||
- Self-hosting requires setup, maintenance, and hosting infrastructure
|
||||
- No session replay or heatmaps
|
||||
- Smaller community compared to more established tools
|
||||
|
||||
### FAQ
|
||||
|
||||
@@ -568,13 +616,74 @@ Swetrix offers a 14-day free trial with no credit card required. After that, you
|
||||
After your 14-day trial, you choose the plan that covers your monthly events. If you exceed your plan's limit, you move up to the next tier or contact sales for a custom enterprise plan.
|
||||
</FaqItem>
|
||||
<FaqItem question="What's the difference between Swetrix and OpenPanel?">
|
||||
Swetrix is a cookieless, open-source web analytics tool focused on pageviews, events, performance, and user flows. OpenPanel also tracks product events and sessions, offers real-time dashboards, individual user views, and multiple SDKs, all cookie-free and GDPR compliant.
|
||||
Swetrix is a cookieless, open source web analytics tool focused on pageviews, events, performance monitoring, and user flows. OpenPanel also offers web analytics but adds product analytics with event tracking, funnels, retention, individual user/session views, and multiple SDKs — all cookieless and GDPR compliant.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
### Alternative to Swetrix
|
||||
**Plausible Analytics**
|
||||
- Open-source, privacy-first web analytics with flat pricing
|
||||
- Open source, privacy-first web analytics with flat pricing
|
||||
- Tracks pageviews and goals with no cookies required
|
||||
- Self-host for free or start hosted plans at \$9/month for 10 000 pageviews
|
||||
- Simple setup and predictable costs
|
||||
- Self-host for free or start hosted plans at $9/month for 10,000 pageviews
|
||||
- Simple setup and predictable costs
|
||||
|
||||
## Pricing Comparison: All 9 Tools at a Glance
|
||||
|
||||
Here's how the cloud/hosted pricing compares across all tools at common event volumes:
|
||||
|
||||
| Monthly Events | OpenPanel | PostHog | Plausible | Matomo Cloud | Fathom | Umami Cloud | Pirsch | Swetrix |
|
||||
|---------------|-----------|---------|-----------|-------------|--------|-------------|--------|---------|
|
||||
| 10K | $5 | $0 | $9 | €29 | $15 | $0 | $6 | $5 |
|
||||
| 100K | $20 | $0 | $14 | €29 | $15 | $0 | ~$12+ | $15 |
|
||||
| 500K | $50 | $0 | ~$19+ | Custom | $45 | $0 | Custom | $45 |
|
||||
| 1M | $90 | $0 | Custom | Custom | $60 | $0 | Custom | $59 |
|
||||
| 5M | $250 | ~$187 | Custom | Custom | $140 | ~$80 | Custom | $110 |
|
||||
| 10M | $350 | ~$393 | Custom | Custom | $200 | ~$180 | Custom | $150 |
|
||||
|
||||
*PostHog and Umami include 1M free events/month. Plausible prices by pageviews, not events. Matomo prices by "hits." Exact pricing varies — check each provider for current rates.*
|
||||
|
||||
## How to Choose the Right Open Source Analytics Tool
|
||||
|
||||
**Choose OpenPanel if** you want the power of product analytics (funnels, retention, user tracking) combined with web analytics, at an affordable price. Best for startups and growing companies.
|
||||
|
||||
**Choose PostHog if** you need an all-in-one developer platform with analytics, feature flags, experiments, error tracking, and surveys. Best for engineering-heavy teams.
|
||||
|
||||
**Choose Plausible if** you want the simplest possible privacy-first web analytics. No complexity, just clean traffic data. Best for blogs, marketing sites, and simple web projects.
|
||||
|
||||
**Choose Matomo if** you need a mature, full-featured Google Analytics replacement with heatmaps, session recordings, and A/B testing. Best for enterprises moving away from GA4. See our full [Google Analytics alternative comparison](/compare/google-analytics-alternative) for a detailed breakdown.
|
||||
|
||||
**Choose Umami if** you want a lightweight, MIT-licensed tool to self-host with minimal setup. Best for developers who want basic analytics on personal projects.
|
||||
|
||||
**Choose Fathom if** you want simple privacy analytics without any self-hosting. Best for businesses that want zero maintenance.
|
||||
|
||||
**Choose Pirsch if** you need server-side tracking that works around ad blockers. Best for sites where tracking accuracy is critical.
|
||||
|
||||
**Choose Swetrix if** you want web analytics plus performance monitoring in one tool. Best for teams focused on web performance.
|
||||
|
||||
**Choose Ackee if** you want the most minimal self-hosted solution possible. Best for personal projects or developers who want maximum simplicity.
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What is the best open source web analytics tool?">
|
||||
The best open source web analytics tool depends on your needs. For combined web and product analytics, OpenPanel offers the best value. For simplicity, Plausible is hard to beat. For an all-in-one developer platform, PostHog is the most comprehensive. For a mature Google Analytics replacement, Matomo has the longest track record. All four offer self-hosting and GDPR compliance.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What is the best open source alternative to Google Analytics?">
|
||||
The top open source alternatives to Google Analytics are Matomo (most similar feature set), Plausible (simplest and most privacy-focused), OpenPanel (adds product analytics), and Umami (lightest weight). All are GDPR compliant and can be self-hosted for free. Matomo is the most direct replacement since it tracks similar metrics to GA4. For a detailed comparison, see our [Google Analytics alternative page](/compare/google-analytics-alternative). Ready to switch? Follow our step-by-step [migration guide](/guides/migrate-from-google-analytics).
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I self-host open source analytics for free?">
|
||||
Yes. OpenPanel, PostHog, Plausible, Matomo, Umami, Ackee, and Swetrix all offer free self-hosting. Your only cost is the server infrastructure, which typically runs $5-50/month for a VPS depending on your traffic volume. Self-hosting gives you complete data ownership and privacy control.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What is the best open source product analytics tool?">
|
||||
For open source product analytics (event tracking, funnels, retention, user journeys), the top options are OpenPanel and PostHog. OpenPanel is more affordable and simpler to self-host, while PostHog offers additional features like feature flags, experiments, and error tracking. Both support self-hosting and have generous free tiers on their cloud platforms.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Which open source analytics tools don't use cookies?">
|
||||
Most modern open source analytics tools are cookieless by design. OpenPanel, Plausible, Umami, Pirsch, Fathom, Swetrix, and Ackee all track without cookies. Matomo offers a cookieless tracking mode. PostHog uses cookies by default but can be configured for cookieless tracking. Cookieless tools don't require consent banners, improving both user experience and data accuracy.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is open source analytics secure?">
|
||||
Open source analytics can be more secure than proprietary alternatives because the code is publicly auditable. With self-hosted solutions, your data never leaves your servers. Tools like OpenPanel, Matomo, and PostHog are GDPR and CCPA compliant by design, with built-in privacy features like IP anonymization and data minimization.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: Recap of 2024
|
||||
description: As we approach the end of the year, I wanted to share a summary of how this year unfolded and how everything began.
|
||||
date: 2024-12-30
|
||||
updated: 2026-02-07
|
||||
tag: Article
|
||||
team: Carl-Gerhard Lindesvärd
|
||||
cover: /content/recap-2024.jpg
|
||||
@@ -19,7 +20,7 @@ And here we are...
|
||||
|
||||
## Inspiration
|
||||
|
||||
I've always been a fan of Mixpanel - they crush it at product analytics. But I felt they were missing something when it came to web analytics.
|
||||
I've always been a fan of Mixpanel - they crush it at product analytics. But I felt they were missing something when it came to [web analytics](/features/web-analytics).
|
||||
|
||||
I also noticed all these privacy-focused web analytics tools popping up in the last few years, which I love. That's where OpenPanel comes in. I wanted to build something that takes privacy seriously, has both web and product analytics, great support for native apps but is still super easy to use, even if you're new to the whole analytics game.
|
||||
|
||||
@@ -53,10 +54,10 @@ Making OpenPanel [open-source](https://git.new/openpanel) and [self-hosted](http
|
||||
|
||||
Exciting developments are coming in 2025. Here are some of the priorities:
|
||||
|
||||
- Revenue tracking 💸
|
||||
- [Revenue tracking](/features/revenue-tracking) 💸
|
||||
- Release Android and iOS apps. 📱
|
||||
- Improve report creation. 📊
|
||||
- Add more conversion rate charts (conversion is one of the best ways to understand your product). 📈
|
||||
- Add more [conversion rate](/features/conversion) charts (conversion is one of the best ways to understand your product). 📈
|
||||
- Enable report sharing. 🔗
|
||||
- Customize the overview page. 🎨
|
||||
- Enhance the real-time view. 📈
|
||||
|
||||
381
apps/public/content/articles/self-hosted-product-analytics.mdx
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
title: "Best Self-Hosted Product Analytics Tools in 2026 (Compared)"
|
||||
description: "Compare the best self-hosted product analytics tools in 2026. Detailed breakdown of OpenPanel, PostHog, Countly, Matomo, and more — with features, pricing, deployment, and honest pros/cons."
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
date: 2026-02-16
|
||||
updated: 2026-02-16
|
||||
cover: /content/self-hosted-analytics.jpg
|
||||
---
|
||||
import { Faqs, FaqItem } from '@/components/faq';
|
||||
|
||||
Product analytics tells you *what users do* inside your product — which features they use, where they drop off, how often they come back, and what paths they take before converting. It's the difference between knowing "we got 10,000 pageviews" and knowing "40% of users who start onboarding never finish step 3."
|
||||
|
||||
If you want those insights without sending user behavior data to a third party, self-hosted product analytics is the way to go.
|
||||
|
||||
But here's the problem: most self-hosted analytics tools only cover **web analytics** — pageviews, traffic sources, and referrals. Product analytics features like funnels, retention, cohorts, and user identification require a different class of tool.
|
||||
|
||||
We compared every self-hosted tool that offers real product analytics capabilities. Here's what's worth your time.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Tool | Focus | License | Self-Host Cost | Cloud Pricing (from) | Best For |
|
||||
|------|-------|---------|---------------|---------------------|----------|
|
||||
| [**OpenPanel**](#openpanel) | Web + Product | AGPL-3.0 | Free | $2.50/mo | Teams wanting Mixpanel-like analytics, self-hosted |
|
||||
| [**PostHog**](#posthog) | Product + Web | MIT | Free | $0 + usage | Dev teams needing analytics + flags + experiments |
|
||||
| [**Countly**](#countly) | Product + Mobile | AGPL-3.0 | Free (Community) | Custom | Mobile-first product analytics |
|
||||
| [**Matomo**](#matomo) | Web (+ Product plugins) | GPL-3.0 | Free (core) | €29/mo | GA replacement with product add-ons |
|
||||
| [**Mitzu**](#mitzu) | Product | Source Available | Paid | Custom | Warehouse-native product analytics |
|
||||
|
||||
## What Makes Product Analytics Different from Web Analytics
|
||||
|
||||
Web analytics answers questions about your **website traffic**: where visitors come from, which pages they view, and how long they stay. Tools like [Plausible](/compare/plausible-alternative), [Umami](/compare/umami-alternative), and [GoatCounter](/compare/goatcounter-alternative) do this well.
|
||||
|
||||
Product analytics goes deeper. It answers questions about **user behavior**:
|
||||
|
||||
- **Funnels**: What percentage of users complete signup? Where exactly do they drop off?
|
||||
- **Retention**: Are users coming back after their first week? First month?
|
||||
- **User journeys**: What do users do before upgrading to a paid plan?
|
||||
- **Cohort analysis**: Do users who onboard on mobile retain better than desktop users?
|
||||
- **Event tracking**: Which features get used? How often? By whom?
|
||||
|
||||
If you're building a SaaS, mobile app, or any product where understanding user behavior matters, you need product analytics — not just pageview counts.
|
||||
|
||||
For a comparison of self-hosted tools focused purely on web analytics, see our [self-hosted web analytics guide](/articles/self-hosted-web-analytics).
|
||||
|
||||
---
|
||||
|
||||
## OpenPanel
|
||||
|
||||
**Best for: Teams that want Mixpanel-like product analytics with a self-hosted option**
|
||||
|
||||
[OpenPanel](/) was built to give you the power of tools like [Mixpanel](/compare/mixpanel-alternative) and [Amplitude](/compare/amplitude-alternative) without sending your data to a third party. It combines web analytics and product analytics in one platform, with cookieless tracking and a straightforward self-hosting setup.
|
||||
|
||||

|
||||
|
||||
### Product Analytics Features
|
||||
|
||||
- **[Funnels](/features/funnels)**: Build multi-step conversion funnels with any events. See where users drop off and break down by properties.
|
||||
- **[Retention](/features/retention)**: Analyze how many users come back over time. Compare retention across cohorts, plans, or acquisition channels.
|
||||
- **[Event tracking](/features/event-tracking)**: Track any custom event with properties. Visualize with charts, tables, and breakdowns.
|
||||
- **User identification**: Link events to individual users. View complete user profiles with their full event history.
|
||||
- **[Session tracking](/features/session-tracking)**: See individual sessions with the full sequence of events and pages.
|
||||
- **Real-time dashboards**: Custom dashboards with multiple chart types, updated in real time.
|
||||
|
||||
### Deployment
|
||||
|
||||
Four commands to self-host:
|
||||
|
||||
```bash
|
||||
git clone -b self-hosting https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hosting && ./setup
|
||||
./start
|
||||
```
|
||||
|
||||
Runs on Docker Compose with ClickHouse, PostgreSQL, and Redis. A mid-range VPS (4 vCPU, 8 GB RAM) handles millions of events. The setup wizard handles configuration, database setup, and SSL.
|
||||
|
||||
Full guide: [How to self-host OpenPanel](/articles/how-to-self-host-openpanel) | [Self-hosting docs](/docs/self-hosting/self-hosting)
|
||||
|
||||
### Pricing
|
||||
|
||||
Self-hosting is free. All features included — the self-hosted version is identical to the cloud version with no feature gating.
|
||||
|
||||
Cloud plans start at $2.50/month (5,000 events) and scale to $90/month for 1 million events. Every tier includes every feature.
|
||||
|
||||
### Pros
|
||||
- Full product analytics (funnels, retention, user profiles) plus web analytics in one tool
|
||||
- Simplest self-hosting setup among tools with comparable features
|
||||
- Cookieless tracking — no consent banners needed
|
||||
- Self-hosted and cloud versions are identical
|
||||
- Multiple SDKs: JavaScript, React, Next.js, Vue, React Native, Flutter, and more
|
||||
|
||||
### Cons
|
||||
- Newer project — smaller community than PostHog or Matomo
|
||||
- No built-in session replay or heatmaps yet
|
||||
- No built-in feature flags or A/B testing
|
||||
|
||||
---
|
||||
|
||||
## PostHog
|
||||
|
||||
**Best for: Engineering teams that want analytics, feature flags, and experiments in one platform**
|
||||
|
||||
[PostHog](https://posthog.com/) is the most feature-rich self-hosted product analytics tool. It bundles product analytics with session replay, feature flags, A/B testing, surveys, and error tracking. If you want a Swiss Army knife for product development, PostHog is the most complete option.
|
||||
|
||||

|
||||
|
||||
### Product Analytics Features
|
||||
|
||||
- **Funnels**: Multi-step funnel analysis with breakdown by user properties
|
||||
- **Retention**: Cohort-based retention analysis
|
||||
- **User paths**: Visualize how users navigate through your product
|
||||
- **Cohorts**: Group users by behavior, properties, or lifecycle stage
|
||||
- **Session replay**: Watch recordings of actual user sessions
|
||||
- **Feature flags**: Roll out features to specific user segments
|
||||
- **A/B testing**: Run experiments and measure impact on metrics
|
||||
- **Surveys**: Collect in-product feedback
|
||||
|
||||
### Deployment
|
||||
|
||||
PostHog self-hosting uses Docker Compose for small deployments or Kubernetes (Helm charts) for production. The infrastructure stack includes PostgreSQL, Redis, ClickHouse, and Kafka — significantly more complex than other options.
|
||||
|
||||
Requirements: Minimum 4 vCPU, 16 GB RAM, 30 GB+ storage. PostHog recommends self-hosting only for up to ~300K events/month. Beyond that, they push you toward their cloud offering.
|
||||
|
||||
### Pricing
|
||||
|
||||
Self-hosting is free under MIT license. Cloud has a generous free tier: 1 million analytics events, 5,000 session recordings, and 1 million feature flag requests per month. Beyond that, usage-based pricing kicks in (roughly $0.00005 per event for the next million).
|
||||
|
||||
### Pros
|
||||
- Most comprehensive feature set of any self-hosted analytics tool
|
||||
- MIT license — very permissive
|
||||
- Generous cloud free tier for getting started
|
||||
- Active community and rapid development cycle
|
||||
|
||||
### Cons
|
||||
- Complex self-hosting with multiple infrastructure dependencies
|
||||
- Resource-heavy — needs 4x the RAM of simpler tools
|
||||
- Self-hosted and cloud versions aren't feature-equivalent
|
||||
- Can be overwhelming — lots of features most teams won't use
|
||||
- PostHog actively discourages self-hosting at scale
|
||||
|
||||
Compare with OpenPanel: [PostHog vs OpenPanel](/compare/posthog-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Countly
|
||||
|
||||
**Best for: Mobile-first product analytics with self-hosting**
|
||||
|
||||
[Countly](https://countly.com/) is a product analytics platform with strong mobile analytics roots. It supports web, mobile, and desktop apps with SDKs for iOS, Android, React Native, Flutter, and more. The Community Edition is self-hostable under AGPL-3.0.
|
||||
|
||||
### Product Analytics Features
|
||||
|
||||
- **Funnels**: Define conversion funnels and track drop-off rates
|
||||
- **Retention**: Cohort retention analysis with configurable time periods
|
||||
- **User profiles**: Individual user tracking with event history
|
||||
- **Segmentation**: Filter and segment users by properties and behaviors
|
||||
- **Push notifications**: Built-in push notification system (mobile)
|
||||
- **Crash reporting**: Crash analytics for mobile apps
|
||||
|
||||
### Deployment
|
||||
|
||||
Countly self-hosts with Docker or manual installation on Linux. It uses MongoDB as its primary database. The setup is straightforward but requires MongoDB expertise for production deployments.
|
||||
|
||||
Requirements: 4 vCPU, 8 GB RAM minimum. MongoDB 6+. More resources needed for high-traffic mobile apps.
|
||||
|
||||
### Pricing
|
||||
|
||||
The Community Edition is free under AGPL-3.0 but has limited features. Enterprise pricing is custom and includes advanced analytics, push notifications, crash reporting, and dedicated support. Expect significant cost for the Enterprise version.
|
||||
|
||||
### Pros
|
||||
- Strong mobile analytics with native SDKs for all major platforms
|
||||
- Built-in push notifications and crash reporting
|
||||
- Established platform with enterprise customers
|
||||
- Good for mobile-first or hybrid app analytics
|
||||
|
||||
### Cons
|
||||
- Community Edition is feature-limited — most product analytics features require Enterprise
|
||||
- Enterprise pricing is opaque and expensive
|
||||
- MongoDB-only — no PostgreSQL or ClickHouse option
|
||||
- UI feels dated compared to newer tools
|
||||
- Smaller open source community than PostHog
|
||||
|
||||
Compare with OpenPanel: [Countly vs OpenPanel](/compare/countly-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Matomo
|
||||
|
||||
**Best for: Organizations that want a Google Analytics replacement with optional product analytics features**
|
||||
|
||||
[Matomo](https://matomo.org/) is primarily a web analytics tool, but it offers product analytics capabilities through its plugin system. Heatmaps, session recordings, A/B testing, and funnels are available as paid plugins — even on self-hosted instances.
|
||||
|
||||

|
||||
|
||||
### Product Analytics Features
|
||||
|
||||
- **Funnels** (paid plugin): Multi-step conversion funnels
|
||||
- **Cohorts** (paid plugin): Group users by behavior and analyze over time
|
||||
- **Heatmaps** (paid plugin): See where users click, scroll, and hover
|
||||
- **Session recordings** (paid plugin): Watch recordings of user sessions
|
||||
- **A/B testing** (paid plugin): Run experiments on your site
|
||||
- **Custom events**: Track custom events and goals
|
||||
|
||||
### Deployment
|
||||
|
||||
Matomo runs on PHP + MySQL/MariaDB. Deploy on any LAMP stack or use their Docker images. More complex than Docker-only tools because it requires a PHP environment and web server configuration.
|
||||
|
||||
Full details in our [open source web analytics comparison](/articles/open-source-web-analytics#matomo).
|
||||
|
||||
### Pricing
|
||||
|
||||
The core platform is free under GPL-3.0. Product analytics plugins are sold separately:
|
||||
- Funnels: €199/year
|
||||
- Heatmaps + Session Recording: €249/year
|
||||
- A/B Testing: €199/year
|
||||
- Cohorts: €79/year
|
||||
|
||||
These costs add up. A full product analytics setup on self-hosted Matomo can cost €500-700+/year in plugin licenses.
|
||||
|
||||
### Pros
|
||||
- Most mature self-hosted analytics platform (since 2007)
|
||||
- Closest feature parity to Google Analytics
|
||||
- Large plugin ecosystem
|
||||
- Strong compliance track record
|
||||
|
||||
### Cons
|
||||
- Product analytics features are paid add-ons, not included in the core
|
||||
- Plugin costs add up significantly
|
||||
- PHP stack requires more maintenance
|
||||
- Not a native product analytics tool — it's web analytics with product features bolted on
|
||||
|
||||
Compare with OpenPanel: [Matomo vs OpenPanel](/compare/matomo-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Mitzu
|
||||
|
||||
**Best for: Data teams that want product analytics on top of their existing data warehouse**
|
||||
|
||||
[Mitzu](https://www.mitzu.io/) takes a different approach. Instead of ingesting data into its own database, it runs analytics queries directly on your existing data warehouse (BigQuery, Snowflake, Databricks, etc.). If you already have event data in a warehouse, Mitzu gives you product analytics without duplicating data.
|
||||
|
||||
### Product Analytics Features
|
||||
|
||||
- **Funnels**: Conversion funnel analysis running on your warehouse
|
||||
- **Retention**: Cohort retention directly on warehouse data
|
||||
- **Segmentation**: Filter users by any property in your warehouse
|
||||
- **User journeys**: Path analysis on warehouse data
|
||||
- **Revenue analytics**: Connect events to revenue data
|
||||
|
||||
### Deployment
|
||||
|
||||
Mitzu deploys as a web application that connects to your existing warehouse. Self-hosting is available but requires a paid license.
|
||||
|
||||
Requirements: An existing data warehouse (BigQuery, Snowflake, Databricks, ClickHouse, or PostgreSQL) with event data already flowing in.
|
||||
|
||||
### Pricing
|
||||
|
||||
Self-hosting requires a paid license (pricing available on request). Cloud plans are also custom-priced. There is no free tier for self-hosting.
|
||||
|
||||
### Pros
|
||||
- No data duplication — queries your existing warehouse
|
||||
- Works with the data infrastructure you already have
|
||||
- Strong for data teams who live in SQL and warehouses
|
||||
- Good for organizations with complex data pipelines
|
||||
|
||||
### Cons
|
||||
- Requires an existing data warehouse with event data
|
||||
- Self-hosting requires a paid license
|
||||
- Not suitable for teams starting from scratch
|
||||
- Smaller community and less documentation
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted Product Analytics vs Cloud: When to Self-Host
|
||||
|
||||
The case for self-hosting product analytics is even stronger than for web analytics. Product analytics involves **user-level data** — individual users, their behaviors, their journeys through your product. This data is more sensitive than aggregate pageview counts.
|
||||
|
||||
### Self-host product analytics when:
|
||||
|
||||
- **You handle sensitive user data.** Healthcare (HIPAA), finance, or any industry where user behavior data is regulated. Self-hosting eliminates third-party data transfers entirely. See our [compliance guide](/articles/better-compliance-self-hosted-analytics).
|
||||
- **You need user-level data control.** Product analytics tracks individual users. Under GDPR, this is personal data. Self-hosting gives you full control over retention, deletion, and access.
|
||||
- **You want cost predictability.** Cloud product analytics pricing scales with events and identified users. At scale, this gets expensive fast. Self-hosting costs are your server costs.
|
||||
- **You have data residency requirements.** Some regulations require data to stay in specific geographic regions. Self-hosting on your own infrastructure solves this.
|
||||
|
||||
### Use cloud when:
|
||||
|
||||
- **You're just getting started with product analytics.** Cloud gets you insights faster. Self-host later when you've validated the tool fits your needs.
|
||||
- **You don't have ops capacity.** Product analytics tools with ClickHouse backends need some database knowledge to maintain.
|
||||
- **You need guaranteed uptime.** If analytics downtime directly impacts product decisions, a managed service with SLAs may be worth the cost.
|
||||
|
||||
With [OpenPanel](/pricing), you can start on Cloud and migrate to self-hosting at any time. The features are identical in both versions.
|
||||
|
||||
---
|
||||
|
||||
## How to Get Started
|
||||
|
||||
### 1. Pick a server
|
||||
|
||||
For self-hosted product analytics, you'll want slightly more resources than for basic web analytics since product analytics involves more complex queries (funnels, retention calculations).
|
||||
|
||||
Recommended starting point: 4 vCPU, 8 GB RAM, 80 GB SSD. This handles most small-to-medium products comfortably.
|
||||
|
||||
- **[Hetzner](https://hetzner.cloud/?ref=7Hq0H5mQh7tM)** — A CX31 (4 vCPU, 8 GB RAM) for around €8.50/month
|
||||
- **DigitalOcean** — Equivalent droplet for ~$48/month
|
||||
- **Vultr** — Similar pricing to DigitalOcean
|
||||
|
||||
### 2. Install and configure
|
||||
|
||||
For OpenPanel:
|
||||
|
||||
```bash
|
||||
git clone -b self-hosting https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hosting && ./setup
|
||||
./start
|
||||
```
|
||||
|
||||
The setup wizard walks you through domain configuration, database setup, SSL, and worker settings. Full guide: [How to self-host OpenPanel](/articles/how-to-self-host-openpanel).
|
||||
|
||||
### 3. Instrument your product
|
||||
|
||||
Product analytics requires event tracking in your codebase. With OpenPanel, install the SDK for your framework:
|
||||
|
||||
```bash
|
||||
npm install @openpanel/nextjs
|
||||
# or @openpanel/react, @openpanel/vue, @openpanel/sdk
|
||||
```
|
||||
|
||||
Then track events where they happen:
|
||||
|
||||
```typescript
|
||||
import { op } from '@openpanel/nextjs';
|
||||
|
||||
// Track a custom event
|
||||
op.track('signup_completed', {
|
||||
plan: 'pro',
|
||||
source: 'landing_page'
|
||||
});
|
||||
|
||||
// Identify a user
|
||||
op.identify({
|
||||
profileId: 'user_123',
|
||||
email: 'user@example.com',
|
||||
plan: 'pro'
|
||||
});
|
||||
```
|
||||
|
||||
See the [SDK documentation](/docs/get-started/install-openpanel) for your specific framework.
|
||||
|
||||
### 4. Build your first funnel
|
||||
|
||||
Once events are flowing, create your first conversion funnel. A common starting point: **Visit landing page > Sign up > Complete onboarding > First key action**. This immediately shows you where users drop off in your core flow.
|
||||
|
||||
---
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What is the best self-hosted product analytics tool?">
|
||||
For most teams, OpenPanel offers the best balance of product analytics features, ease of self-hosting, and cost. PostHog is more feature-rich (session replay, feature flags) but significantly more complex to self-host. Countly is best for mobile-first products.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I self-host Mixpanel or Amplitude?">
|
||||
No. Mixpanel and Amplitude are cloud-only platforms with no self-hosting option. The closest self-hosted alternatives are OpenPanel (most similar to Mixpanel in product analytics features) and PostHog (broader feature set with more complexity).
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What is the difference between product analytics and web analytics?">
|
||||
Web analytics tracks website traffic — pageviews, referrals, devices, and geographic data. Product analytics tracks user behavior — events, funnels, retention, cohorts, and user journeys. Most self-hosted tools only offer web analytics. OpenPanel, PostHog, and Countly offer both. See our [self-hosted web analytics comparison](/articles/self-hosted-web-analytics) for web-focused tools.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How much does self-hosted product analytics cost?">
|
||||
The software is free for OpenPanel (AGPL-3.0) and PostHog (MIT). Your cost is server infrastructure — typically $10-50/month for a VPS, depending on traffic. Countly's Enterprise features and Matomo's product analytics plugins require paid licenses on top of server costs.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I need separate tools for web and product analytics?">
|
||||
Not necessarily. OpenPanel and PostHog both combine web analytics and product analytics in one platform. This means a single tracking snippet, one dashboard, and unified user profiles. If you only need web analytics, simpler tools like Plausible or Umami are better suited.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is self-hosted product analytics GDPR compliant?">
|
||||
Self-hosting is one of the strongest approaches for GDPR compliance because user data never leaves your infrastructure. Combined with cookieless tracking (available in OpenPanel), you can run product analytics without consent banners and with full control over data retention and deletion.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I start with cloud and migrate to self-hosting later?">
|
||||
With OpenPanel, yes. The cloud and self-hosted versions are identical in features. You can start with Cloud to validate the tool and migrate to self-hosting when you're ready. PostHog also supports both, though there are some feature differences between their cloud and self-hosted versions.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
@@ -1,92 +1,405 @@
|
||||
---
|
||||
title: Self-hosted web analytics
|
||||
description: Self-host your web analytics on your own infrastructure.
|
||||
tag: Hosting
|
||||
title: "Best Self-Hosted Web Analytics Tools in 2026 (Compared)"
|
||||
description: "Compare the best self-hosted web analytics tools in 2026. Detailed breakdown of OpenPanel, Plausible, Matomo, Umami, GoatCounter, and more — with pricing, features, deployment, and honest pros/cons."
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
date: 2024-11-14
|
||||
updated: 2026-02-16
|
||||
cover: /content/self-hosted-analytics.jpg
|
||||
---
|
||||
import { Faqs, FaqItem } from '@/components/faq';
|
||||
|
||||
In the digital age, understanding website traffic is crucial for any online presence. However, traditional analytics tools often come with privacy concerns and complex interfaces.
|
||||
Self-hosted web analytics gives you something cloud-only tools can't: **complete ownership of your data**. No third-party servers. No vendor lock-in. No surprise pricing changes. Your analytics data lives on your infrastructure, under your control.
|
||||
|
||||
Self-hosted web analytics empowers you to maintain control over your data while gaining valuable insights into your website's performance. By hosting your own analytics platform, you can ensure data privacy and tailor the tool to your specific needs.
|
||||
But which self-hosted analytics tool should you actually use? There are over a dozen options, and they vary wildly in features, deployment complexity, and what they're good at.
|
||||
|
||||
## Why Choose Self-Hosted Web Analytics?
|
||||
We tested and compared the most popular self-hosted web analytics tools so you don't have to. Here's what we found.
|
||||
|
||||
You own your data - completely. No more worrying about what third-party services are doing with your information.
|
||||
## Quick Comparison
|
||||
|
||||
Think about it: with your own analytics setup, you're in the driver's seat. You can:
|
||||
- Keep your data private and GDPR-compliant
|
||||
- Customize everything to work exactly how you want
|
||||
- Skip paying for expensive services (but keep in mind that you'll have to pay for your own server and maintenance)
|
||||
| Tool | Type | License | Deployment | Cloud Option | Best For |
|
||||
|------|------|---------|------------|-------------|----------|
|
||||
| [**OpenPanel**](#openpanel) | Web + Product | AGPL-3.0 | Docker Compose | From $2.50/mo | Teams needing web and product analytics |
|
||||
| [**Plausible**](#plausible) | Web | AGPL-3.0 | Docker Compose | From $9/mo | Simple, privacy-first pageview tracking |
|
||||
| [**Matomo**](#matomo) | Web | GPL-3.0 | PHP + MySQL | From €29/mo | Full Google Analytics replacement |
|
||||
| [**Umami**](#umami) | Web | MIT | Docker Compose | Free tier | Lightweight self-hosted analytics |
|
||||
| [**GoatCounter**](#goatcounter) | Web | EUPL | Single binary | Free tier | Minimalist analytics for small sites |
|
||||
| [**PostHog**](#posthog) | Product + Web | MIT | Docker/K8s | Free tier | Developer teams needing analytics + flags |
|
||||
| [**Ackee**](#ackee) | Web | MIT | Docker + MongoDB | None | Privacy-focused minimalist tracking |
|
||||
|
||||
If you've been using tools like Mixpanel or Google Analytics, you might be surprised by how liberating it feels to have full control over your analytics.
|
||||
## How to Choose the Right Self-Hosted Analytics Tool
|
||||
|
||||
## Self-Hosted Analytics as an Alternative to Mixpanel and Google Analytics
|
||||
Before picking a tool, think about what you actually need:
|
||||
|
||||
While Google Analytics and Mixpanel are powerful tools, they come with their own set of limitations, particularly regarding data privacy and customization. Here’s how self-hosted analytics can serve as a viable alternative:
|
||||
**Web analytics vs product analytics.** If you only need pageviews, traffic sources, and referrals, a simple web analytics tool like Plausible or Umami will do. If you also need [funnels](/features/funnels), [retention analysis](/features/retention), user identification, and [event tracking](/features/event-tracking), you need a tool with product analytics capabilities.
|
||||
|
||||
- **Google Analytics Alternatives**: Google Analytics is a comprehensive tool, but it often involves data sharing with Google itself. Self-hosted solutions eliminate this concern, providing similar functionalities without compromising privacy.
|
||||
**Deployment complexity.** Some tools deploy in under 5 minutes with a single Docker Compose command. Others need Kubernetes clusters, multiple databases, or PHP environments. Be honest about your ops capacity.
|
||||
|
||||
- [**Mixpanel Alternatives**](/articles/alternatives-to-mixpanel): Mixpanel is known for its in-depth user analytics but can be costly and complex. Self-hosted alternatives offer a more cost-effective solution with the ability to customize and scale based on your unique requirements.
|
||||
**Maintenance burden.** Self-hosting means you handle updates, backups, and scaling. Simpler tools like GoatCounter or Umami need minimal maintenance. Feature-rich tools like Matomo or PostHog require more attention.
|
||||
|
||||
## Simple analytics is key
|
||||
**Privacy and compliance.** All self-hosted tools keep data on your servers. But some go further with cookieless tracking (no consent banners needed), while others still use cookies by default.
|
||||
|
||||
Most of the analytics tools out there are complex, feature packed and have a steep learning curve.
|
||||
**Cost at scale.** Self-hosting isn't free — you pay for server infrastructure. A $5/month VPS handles most small-to-medium sites, but high-traffic sites need more resources.
|
||||
|
||||
**Look at Google Analytics**, for example. It's a powerful tool, but most often people don't even use a fraction of what it offers.
|
||||
---
|
||||
|
||||
It's because they have made a tool that is so complex that it's hard to know where to start.
|
||||
## OpenPanel
|
||||
|
||||
**Mixpanel has done it right**, but with their constant pricing changes, you never know what you're paying for.
|
||||
**Best for: Teams that need both web analytics and product analytics in one self-hosted tool**
|
||||
|
||||
Open-source tools tend to be simpler and easier to understand, since they don't have to deal with the complexity of a big company with lots of stakeholders.
|
||||
[OpenPanel](/) combines [web analytics](/features/web-analytics) and product analytics in a single platform. You get pageviews and traffic sources alongside funnels, retention, user journeys, and event tracking. It uses cookieless tracking by default, which means no consent banners.
|
||||
|
||||
## Privacy and security
|
||||

|
||||
|
||||
With growing concerns about data privacy, using a self-hosted solution ensures that your website's data is stored securely on your own servers. This approach minimizes the risk of data breaches and unauthorized access, providing peace of mind to both you and your website visitors.
|
||||
### Deployment
|
||||
|
||||
By removing the need for cookie consent banners, you create a smoother user experience while still adhering to privacy standards. This level of control makes self-hosted solutions attractive for those prioritizing privacy.
|
||||
OpenPanel is one of the easiest tools to self-host. Four commands and you're running:
|
||||
|
||||
## Setting Up Your Self-Hosted Analytics
|
||||
```bash
|
||||
git clone -b self-hosting https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hosting && ./setup
|
||||
./start
|
||||
```
|
||||
|
||||
Transitioning to a self-hosted analytics solution may seem daunting, but it can be straightforward with the right approach:
|
||||
The setup wizard handles Docker, database configuration, and SSL. It runs on a single VPS with Docker Compose. A mid-range VPS (4 vCPU, 8 GB RAM) handles millions of events per month.
|
||||
|
||||
### 1. Pick a server provider
|
||||
For detailed instructions, see the [self-hosting guide](/docs/self-hosting/self-hosting).
|
||||
|
||||
Picking a server provider not an easy task. There are many things to consider, such as cost, performance, and security. We would advice you to do your own research and choose a provider that fits your needs.
|
||||
### Key Features
|
||||
- Real-time web analytics dashboard with traffic, referrals, pages, and devices
|
||||
- Product analytics: funnels, retention, user identification, custom events
|
||||
- Cookieless tracking — GDPR/CCPA compliant without consent banners
|
||||
- Multiple SDKs (JavaScript, React, Next.js, Vue, and more)
|
||||
- Self-hosted version is identical to the cloud version — no feature gating
|
||||
|
||||
Here is a list of some providers that differ in both price and complexity:
|
||||
### Pricing
|
||||
Self-hosting is free. Cloud plans start at $2.50/month for up to 5,000 events, scaling to $90/month for 1 million events. All features included at every tier.
|
||||
|
||||
- **AWS** - Complex but you can do almost anything
|
||||
- **Google Cloud** - Complex but you can do almost anything
|
||||
- **DigitalOcean** - Easy to use but limited, great price
|
||||
- **Hetzner** - Easy to use but limited, best price!
|
||||
- **Vultr** - Easy to use but limited, great price
|
||||
### Pros
|
||||
- Only self-hosted tool that combines web and product analytics at this price point
|
||||
- Simple Docker Compose deployment with guided setup
|
||||
- Cookieless by default — no cookie banners needed
|
||||
- Open source (AGPL-3.0) with no feature differences between cloud and self-hosted
|
||||
|
||||
### 2. Pick a open-source analytics platform
|
||||
### Cons
|
||||
- Newer project with a smaller community than Matomo or PostHog
|
||||
- No built-in session replay or heatmaps (yet)
|
||||
- Self-hosting still requires server maintenance
|
||||
|
||||
We're of course biased, but we think OpenPanel is the best self-hosted analytics platform out there since it has both product and web analytics. Most of the self-hosted analytics tools out there is just for web analytics.
|
||||
---
|
||||
|
||||
So depending on your needs you might consider what you choose.
|
||||
## Plausible
|
||||
|
||||
- **Plausible** - Simple and privacy focused
|
||||
- **Simple Analytics** - Simple and privacy focused
|
||||
- **Fathom** - Simple and privacy focused
|
||||
- **OpenPanel** - Best of both worlds, easy to use and privacy focused
|
||||
**Best for: Simple, privacy-first pageview analytics with minimal setup**
|
||||
|
||||
Each of these platforms has guides how to set up on your own server, here is ours: [How to install OpenPanel on your own server](/docs/self-hosting/self-hosting)
|
||||
[Plausible](https://plausible.io/self-hosted-web-analytics) is the go-to choice if you want clean, simple web analytics without complexity. It tracks pageviews, referrals, and goals without cookies. The dashboard fits on a single screen — no training required.
|
||||
|
||||
### 3. Secure your server
|
||||

|
||||
|
||||
Before you start installning your analytics platform, you should secure your server.
|
||||
### Deployment
|
||||
|
||||
Read our guide on [how to secure your server](/articles/how-to-secure-ubuntu-server) for more information.
|
||||
Plausible self-hosts with Docker Compose. The setup involves cloning their hosting repo, configuring a `plausible-conf.env` file, and running `docker compose up`.
|
||||
|
||||
### 4. Install your analytics platform
|
||||
Requirements: A server with Docker, at least 2 GB RAM. Uses PostgreSQL and ClickHouse under the hood.
|
||||
|
||||
Each of the platforms has guides how to install on your own server. Usually this information is available on the project's website or GitHub repository.
|
||||
### Key Features
|
||||
- Single-screen dashboard with real-time data
|
||||
- Cookieless tracking — no consent banners
|
||||
- Goal tracking and custom events
|
||||
- Google Search Console integration
|
||||
- Lightweight script (~1 KB)
|
||||
|
||||
## Conclusion
|
||||
### Pricing
|
||||
Self-hosting is free under AGPL-3.0. Cloud plans start at $9/month for 10,000 pageviews.
|
||||
|
||||
Self-hosted web analytics is a strategic move for anyone looking to prioritize privacy and control over their website data. By opting for a solution that aligns with your values and needs, you can gain valuable insights without compromising on security. For those exploring [Mixpanel alternatives](/articles/alternatives-to-mixpanel) or Google Analytics alternatives, self-hosted platforms provide a robust and private solution. Take the leap into self-hosting today and experience the benefits of a more private and streamlined analytics process.
|
||||
### Pros
|
||||
- The simplest analytics dashboard available — anyone can use it
|
||||
- Very lightweight tracking script that won't slow your site
|
||||
- Active open source community
|
||||
- Clean Docker Compose deployment
|
||||
|
||||
### Cons
|
||||
- No product analytics (no funnels, retention, or user-level tracking)
|
||||
- No session replay or heatmaps
|
||||
- Self-hosted version requires manual updates
|
||||
- AGPL license requires source disclosure if you modify and distribute
|
||||
|
||||
Compare with OpenPanel: [Plausible vs OpenPanel](/compare/plausible-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Matomo
|
||||
|
||||
**Best for: Organizations that need a full Google Analytics replacement**
|
||||
|
||||
[Matomo](https://matomo.org/) (formerly Piwik) has been around since 2007 and is the most feature-rich self-hosted web analytics tool available. If you're migrating from Google Analytics and want a similar experience under your own control, Matomo is the closest match.
|
||||
|
||||

|
||||
|
||||
### Deployment
|
||||
|
||||
Matomo runs on PHP + MySQL/MariaDB. You can deploy it on any LAMP stack or use their Docker images. The setup is more involved than Docker-only tools — you'll need PHP, a web server (Apache/Nginx), and a database.
|
||||
|
||||
Requirements: PHP 8.1+, MySQL 8+ or MariaDB 10.4+, at least 2 GB RAM. For high traffic (1M+ pageviews/month), separate app and database servers are recommended.
|
||||
|
||||
### Key Features
|
||||
- Full web analytics suite (traffic, referrals, campaigns, goals, ecommerce)
|
||||
- Heatmaps and session recordings (paid plugins)
|
||||
- A/B testing (paid plugin)
|
||||
- Google Analytics data import
|
||||
- Large plugin ecosystem
|
||||
|
||||
### Pricing
|
||||
Self-hosting the core platform is free under GPL-3.0. However, advanced features like heatmaps, session recordings, and A/B testing require paid plugin subscriptions even on self-hosted instances. Cloud starts at €29/month.
|
||||
|
||||
### Pros
|
||||
- Most mature and feature-rich self-hosted analytics tool
|
||||
- Closest replacement for Google Analytics
|
||||
- Huge plugin ecosystem
|
||||
- Strong compliance track record (used by governments and enterprises)
|
||||
|
||||
### Cons
|
||||
- PHP stack is more complex to deploy and maintain than Docker-only tools
|
||||
- Premium plugins add cost even on self-hosted
|
||||
- UI feels dated compared to newer tools
|
||||
- Resource-heavy at scale
|
||||
|
||||
Compare with OpenPanel: [Matomo vs OpenPanel](/compare/matomo-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Umami
|
||||
|
||||
**Best for: Developers who want a lightweight, MIT-licensed analytics tool**
|
||||
|
||||
[Umami](https://umami.is/) is a lightweight, privacy-focused web analytics tool. It's one of the simplest tools to self-host and maintain. If you want basic web analytics with minimal overhead, Umami delivers.
|
||||
|
||||

|
||||
|
||||
### Deployment
|
||||
|
||||
Umami self-hosts with Docker Compose or directly with Node.js. Clone the repo, configure your database connection, and start. It supports PostgreSQL and MySQL.
|
||||
|
||||
Requirements: Node 18+ with PostgreSQL 12+ or MySQL 8+. Very lightweight — runs comfortably on a $5/month VPS for small to medium sites.
|
||||
|
||||
### Key Features
|
||||
- Clean dashboard with real-time data
|
||||
- Pageviews, referrals, devices, and basic events
|
||||
- Cookieless tracking
|
||||
- Multiple website support
|
||||
- API access for custom integrations
|
||||
|
||||
### Pricing
|
||||
Self-hosting is completely free under MIT license. Umami Cloud offers a free tier with 1 million events/month, then $0.00002 per additional event.
|
||||
|
||||
### Pros
|
||||
- MIT license — the most permissive license among comparable tools
|
||||
- Very lightweight and easy to maintain
|
||||
- Simple deployment with Docker Compose
|
||||
- Free cloud tier for those who want to try before self-hosting
|
||||
|
||||
### Cons
|
||||
- Limited to basic web metrics
|
||||
- No funnels, retention, or product analytics
|
||||
- Fewer features than Plausible (no Google Search Console integration)
|
||||
- No session replay or heatmaps
|
||||
|
||||
Compare with OpenPanel: [Umami vs OpenPanel](/compare/umami-alternative)
|
||||
|
||||
---
|
||||
|
||||
## GoatCounter
|
||||
|
||||
**Best for: Minimalist analytics for personal sites and blogs**
|
||||
|
||||
[GoatCounter](https://goatcounter.com/) takes minimalism to the extreme. It's a single Go binary that tracks pageviews without cookies. No Docker, no complex setup — just download and run.
|
||||
|
||||
### Deployment
|
||||
|
||||
GoatCounter ships as a single binary. Download it, run it, point your domain at it. You can also run it with SQLite (default) or PostgreSQL. No Docker required, though Docker images are available.
|
||||
|
||||
Requirements: Minimal. Runs on the smallest VPS you can find. SQLite mode needs almost no resources.
|
||||
|
||||
### Key Features
|
||||
- Pageview tracking without cookies or JavaScript (optional)
|
||||
- Single binary deployment
|
||||
- SQLite or PostgreSQL backend
|
||||
- Basic referral and browser tracking
|
||||
- Public dashboard option
|
||||
|
||||
### Pricing
|
||||
Self-hosting is free under EUPL license. The hosted service is free for non-commercial use, with paid plans starting at €5/month.
|
||||
|
||||
### Pros
|
||||
- Simplest deployment of any analytics tool — single binary
|
||||
- Runs on minimal resources
|
||||
- Can work without JavaScript (server-side counting)
|
||||
- Free hosted tier for personal sites
|
||||
|
||||
### Cons
|
||||
- Very basic feature set — pageviews and referrals only
|
||||
- No event tracking or custom properties
|
||||
- No product analytics features
|
||||
- Small development team (mostly one person)
|
||||
|
||||
---
|
||||
|
||||
## PostHog
|
||||
|
||||
**Best for: Developer teams that need analytics, feature flags, and experiments in one platform**
|
||||
|
||||
[PostHog](https://posthog.com/) is an all-in-one product analytics platform with web analytics, session replay, feature flags, A/B testing, and surveys. It's the most feature-rich option here, but also the most complex to self-host.
|
||||
|
||||

|
||||
|
||||
### Deployment
|
||||
|
||||
PostHog self-hosting uses Docker Compose for small deployments or Kubernetes via Helm charts for production. The setup requires PostgreSQL, Redis, ClickHouse, and Kafka. It's significantly more complex than other tools on this list.
|
||||
|
||||
Requirements: Minimum 4 vCPU, 16 GB RAM, 30 GB+ storage. The team recommends self-hosting only for up to ~300K events/month; beyond that, PostHog Cloud handles scaling better.
|
||||
|
||||
### Key Features
|
||||
- Product analytics with funnels, retention, and cohorts
|
||||
- Web analytics dashboard
|
||||
- Session replay
|
||||
- Feature flags and A/B testing
|
||||
- Surveys and error tracking
|
||||
|
||||
### Pricing
|
||||
Self-hosting is free under MIT license. Cloud has a generous free tier (1M analytics events, 5K session recordings/month), then usage-based pricing.
|
||||
|
||||
### Pros
|
||||
- Most comprehensive feature set — replaces multiple tools
|
||||
- MIT license
|
||||
- Generous cloud free tier
|
||||
- Active community and rapid development
|
||||
|
||||
### Cons
|
||||
- Complex self-hosting setup with multiple infrastructure dependencies
|
||||
- Resource-heavy — needs significantly more server power
|
||||
- Self-hosted vs cloud is not feature-equivalent
|
||||
- Overkill if you just need web analytics
|
||||
|
||||
Compare with OpenPanel: [PostHog vs OpenPanel](/compare/posthog-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Ackee
|
||||
|
||||
**Best for: Privacy-focused minimalist tracking on small projects**
|
||||
|
||||
[Ackee](https://github.com/electerious/Ackee) is a self-hosted analytics tool that focuses on privacy and minimalism. It tracks pageviews and basic events with a clean, minimal interface.
|
||||
|
||||
### Deployment
|
||||
|
||||
Ackee runs on Node.js with MongoDB. Deploy with Docker Compose or directly on a server with Node and MongoDB installed.
|
||||
|
||||
Requirements: Node 18+, MongoDB 7+. Lightweight on resources.
|
||||
|
||||
### Key Features
|
||||
- Pageview and event tracking
|
||||
- Cookieless, anonymized tracking
|
||||
- GraphQL API for custom integrations
|
||||
- Multiple domain support
|
||||
- Minimal, clean UI
|
||||
|
||||
### Pricing
|
||||
Completely free to self-host under MIT license. No cloud offering from the team.
|
||||
|
||||
### Pros
|
||||
- Fully free with MIT license
|
||||
- Privacy-first with anonymized data
|
||||
- GraphQL API for flexible querying
|
||||
- Very simple and focused
|
||||
|
||||
### Cons
|
||||
- Requires MongoDB (adds complexity vs PostgreSQL-based tools)
|
||||
- Minimal feature set
|
||||
- No cloud option
|
||||
- Development has slowed in recent years
|
||||
|
||||
Compare with OpenPanel: [Ackee vs OpenPanel](/compare/ackee-alternative)
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted vs Cloud Analytics: When to Self-Host
|
||||
|
||||
Self-hosting isn't always the right choice. Here's when it makes sense — and when it doesn't.
|
||||
|
||||
### Self-host when:
|
||||
|
||||
- **You need data sovereignty.** Regulations like GDPR, HIPAA, or industry-specific rules require data to stay on your infrastructure. Self-hosting eliminates third-party data transfers entirely. Read more in our [compliance guide](/articles/better-compliance-self-hosted-analytics).
|
||||
- **You want predictable costs.** Cloud analytics pricing scales with events or pageviews. Self-hosting costs are your server costs, which are more predictable and often cheaper at scale.
|
||||
- **You have ops capacity.** Someone on your team can manage Docker, handle updates, and monitor uptime.
|
||||
- **You want full control.** Custom retention policies, direct database access, integration with internal systems.
|
||||
|
||||
### Use cloud when:
|
||||
|
||||
- **You want zero maintenance.** No server management, no updates, no uptime monitoring.
|
||||
- **You're just getting started.** Cloud gets you analytics in minutes. Self-hosting takes longer to set up.
|
||||
- **Your team is small.** If nobody on the team is comfortable managing infrastructure, cloud is safer.
|
||||
- **You need guaranteed uptime.** Cloud providers handle redundancy and failover.
|
||||
|
||||
Most self-hosted analytics tools also offer cloud versions. With [OpenPanel](/pricing), you can start with Cloud and migrate to self-hosting later — or vice versa — since the features are identical.
|
||||
|
||||
## How to Get Started with Self-Hosted Analytics
|
||||
|
||||
### 1. Pick a server
|
||||
|
||||
You need a VPS. For most self-hosted analytics tools, a server with 2-4 vCPU, 4-8 GB RAM, and 40 GB SSD is enough to start.
|
||||
|
||||
Recommended providers:
|
||||
- **[Hetzner](https://hetzner.cloud/?ref=7Hq0H5mQh7tM)** — Best price-to-performance. A CX31 (4 vCPU, 8 GB RAM) costs around €8.50/month.
|
||||
- **DigitalOcean** — Easy to use, good documentation. Droplets from $6/month.
|
||||
- **Vultr** — Similar to DigitalOcean with competitive pricing.
|
||||
- **AWS/GCP** — More complex, but useful if you're already in their ecosystem.
|
||||
|
||||
### 2. Secure your server
|
||||
|
||||
Before installing anything, lock down your server. At minimum: disable root login, set up SSH keys, configure a firewall. We have a full guide on [how to secure your Ubuntu server](/articles/how-to-secure-ubuntu-server).
|
||||
|
||||
### 3. Install your analytics tool
|
||||
|
||||
Each tool has its own installation process. For OpenPanel, it's four commands:
|
||||
|
||||
```bash
|
||||
git clone -b self-hosting https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hosting && ./setup
|
||||
./start
|
||||
```
|
||||
|
||||
The setup wizard handles everything — Docker, databases, SSL certificates. Full walkthrough: [How to self-host OpenPanel](/articles/how-to-self-host-openpanel).
|
||||
|
||||
### 4. Add tracking to your site
|
||||
|
||||
Install the tracking script on your website. With OpenPanel, you add a small JavaScript snippet or use one of the framework-specific SDKs (React, Next.js, Vue, and more). Check the [SDK documentation](/docs/get-started/install-openpanel) for your stack.
|
||||
|
||||
---
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="What is the best self-hosted web analytics tool?">
|
||||
It depends on your needs. For combined web and product analytics, OpenPanel offers the best value. For simple pageview tracking, Plausible is the most popular. For a full Google Analytics replacement, Matomo has the most features. For the lightest option, GoatCounter or Umami work well.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is self-hosted analytics free?">
|
||||
The software is free for most tools (OpenPanel, Plausible, Matomo, Umami, GoatCounter, PostHog, and Ackee all offer free self-hosting). Your cost is the server infrastructure — typically $5-50/month for a VPS depending on your traffic volume.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I need a cookie banner with self-hosted analytics?">
|
||||
Not if your tool uses cookieless tracking. OpenPanel, Plausible, Umami, GoatCounter, and Ackee all track without cookies, so no cookie consent banner is needed for basic analytics. Matomo uses cookies by default but offers a cookieless mode.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I self-host analytics and be GDPR compliant?">
|
||||
Yes. Self-hosting is one of the strongest approaches for GDPR compliance because your data never leaves your infrastructure. There are no third-party data transfers to worry about, and you have full control over data retention and deletion. Tools like OpenPanel and Plausible add cookieless tracking on top, making compliance even simpler.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How much server resources do I need for self-hosted analytics?">
|
||||
For most tools: 2-4 vCPU, 4-8 GB RAM, and 40 GB SSD handles up to a few hundred thousand events per month. PostHog needs more (16 GB RAM minimum). GoatCounter and Ackee run on minimal resources. Scale up as your traffic grows.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What is the difference between self-hosted web analytics and product analytics?">
|
||||
Web analytics tracks website traffic — pageviews, referrals, devices, and geographic data. Product analytics tracks user behavior within your product — events, funnels, retention, and user journeys. Some tools like OpenPanel and PostHog offer both. Most self-hosted tools (Plausible, Umami, GoatCounter) focus on web analytics only. See our guide on [self-hosted product analytics](/articles/self-hosted-product-analytics) for tools that cover the product side.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I migrate from Google Analytics to a self-hosted tool?">
|
||||
Yes. Matomo offers direct Google Analytics data import. Other tools like OpenPanel and Plausible let you start fresh with their tracking. You can run both in parallel during the transition to make sure you're comfortable with the new tool before removing GA.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
title: Mixpanel vs OpenPanel
|
||||
description: A comparison between Mixpanel and OpenPanel
|
||||
date: 2024-11-13
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
cover: /content/cover-mixpanel.jpg
|
||||
---
|
||||
import { Figure } from "@/components/figure";
|
||||
|
||||
OpenPanel is based on the same principles as Mixpanel, but with a few key differences. We'll go through some of the features and see how they compare.
|
||||
|
||||
## Web analytics
|
||||
|
||||
Mixpanel is a great product analytics tool but in our minds its lacking in this area. Web analytics should always be easy to get going and we think Mixpanel has to much focus on product analytics.
|
||||
|
||||
In OpenPanel you do not need to do anything to get your web analytics up and running. Just add the tracking snippet to your website or app and you're up and running.
|
||||
|
||||
<Figure
|
||||
src="/content/screenshot-web-analytics.png"
|
||||
alt="OpenPanel web analytics dashboard showing pageviews, sessions and other key metrics"
|
||||
caption="OpenPanel's web analytics dashboard provides key metrics at a glance"
|
||||
/>
|
||||
|
||||
## Product analytics
|
||||
|
||||
Mixpanel's strength is in product analytics and it's hard to beat (to be honest). Nevertheless we aim to have the same great features in OpenPanel.
|
||||
|
||||
Probably the most used feature in Mixpanel is their report tool, where you can create all kinds of charts and see how different things are doing. We have tried to make a similar experiance where you can pick and choose different metrics and dimensions to create your own custom reports.
|
||||
|
||||
Some of the features we have added are:
|
||||
|
||||
- **Funnels**
|
||||
- **Retention**
|
||||
- **Line charts**
|
||||
- **Bar charts**
|
||||
- **Histogram charts**
|
||||
- **Area charts**
|
||||
- **Pie charts**
|
||||
- **Map charts**
|
||||
- **Events**
|
||||
- **Profiles**
|
||||
|
||||
<Figure
|
||||
src="/content/screenshot-report-funnel.png"
|
||||
alt="OpenPanel report tool showing a funnel"
|
||||
caption="OpenPanel's report tool provides a wide range of charts and metrics"
|
||||
/>
|
||||
|
||||
## Cookies vs Cookieless
|
||||
|
||||
Mixpanel is a cookie-based tool, which means that it relies on cookies to track users. This provides advantages like:
|
||||
- More accurate user identification across sessions
|
||||
- Better cross-domain tracking
|
||||
- Easier integration with existing cookie-based systems
|
||||
|
||||
However, it also comes with challenges:
|
||||
- Requires cookie consent banners in many jurisdictions
|
||||
- Can be blocked by ad blockers and privacy-focused browsers
|
||||
- May not work with upcoming cookie restrictions
|
||||
|
||||
OpenPanel uses a cookieless approach, relying instead on privacy-preserving techniques like fingerprinting and session-based tracking. This offers benefits such as:
|
||||
- No cookie consent banners required
|
||||
- Works even when cookies are blocked
|
||||
- Future-proof against upcoming cookie restrictions
|
||||
|
||||
> Its up to you to decide what's best for your users and your business.
|
||||
|
||||
## Realtime
|
||||
|
||||
Both Mixpanel and OpenPanel have real-time analytics. Its just a matter of seconds before you can see what's happening in your product or website.
|
||||
|
||||
But we have added a new feature in OpenPanel which we call `Realtime`. It's similar to Google Analytics' real-time view since we love looking at big screens with live data.
|
||||
|
||||
<Figure
|
||||
src="/content/screenshot-realtime.png"
|
||||
alt="OpenPanel real-time analytics dashboard showing active users and other key metrics"
|
||||
caption="See where all your users are at the moment in OpenPanel's realtime view"
|
||||
/>
|
||||
|
||||
## Notifications
|
||||
|
||||
In OpenPanel you can create notifications for different events. This is a great way to stay on top of things and get notified when something is happening.
|
||||
|
||||
You can define advanced conditions when and what to notify you about. We have several integrations with other tools so you can easily connect your notifications to other tools you use.
|
||||
|
||||
As of now, we don't believe Mixpanel has this feature.
|
||||
|
||||
## Similarities
|
||||
|
||||
### Profiles
|
||||
|
||||
Both Mixpanel and OpenPanel allow you to see profiles of your users. This is a great way to understand your users and see how they are doing.
|
||||
|
||||
### Events
|
||||
|
||||
You get new events in realtime in both Mixpanel and OpenPanel, you can search and filter on any property. Mixpanel might be a bit faster but it's not a big difference.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Mixpanel is a great product analytics tool but in our minds its lacking in this area. Web analytics should always be easy to get going and we think Mixpanel has to much focus on product analytics.
|
||||
|
||||
**OpenPanel is a great alternative to Mixpanel** if you want to get started with analytics quickly and easily.
|
||||
552
apps/public/content/compare/ackee-alternative.json
Normal file
@@ -0,0 +1,552 @@
|
||||
{
|
||||
"slug": "ackee-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best Ackee Alternative 2026 - Open Source & Free",
|
||||
"description": "Looking for an Ackee alternative? OpenPanel offers deeper product analytics with funnels, retention, and user identification \u2014 plus cloud and self-hosting options. Open source and free to start.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Best Ackee Alternative",
|
||||
"subheading": "Love Ackee's privacy-first approach and self-hosting simplicity? OpenPanel adds product analytics capabilities - funnels, cohorts, retention, and user identification - with a modern architecture built for scale.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Privacy-first",
|
||||
"Product Analytics",
|
||||
"Self-hostable"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Ackee",
|
||||
"logo": "/logos/ackee.svg",
|
||||
"url": "https://ackee.electerious.com",
|
||||
"short_description": "Minimalist, self-hosted, privacy-focused web analytics with anonymous tracking and a clean interface.",
|
||||
"founded": 2016,
|
||||
"headquarters": "Germany"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Why consider OpenPanel over Ackee?",
|
||||
"paragraphs": [
|
||||
"Ackee is an ultra-minimal, self-hosted analytics tool that prioritizes anonymous tracking and a clean interface. Built on MongoDB, it's a solid choice for developers who want basic web metrics on their own server without any third-party data sharing. But its intentional minimalism means no user identification, no product analytics, and no cloud-hosted option.",
|
||||
"OpenPanel provides everything Ackee offers \u2014 privacy-focused, open-source, and self-hostable \u2014 while adding the product analytics capabilities that help you actually grow your product. Funnels let you understand where users drop off, retention analysis shows whether they come back, and user identification connects anonymous visits to real accounts once users sign in.",
|
||||
"The database architecture is a fundamental difference. Ackee uses MongoDB, which works for small sites but isn't designed for analytical queries. OpenPanel uses ClickHouse, a column-oriented database purpose-built for analytics, which means fast queries even with millions of events. And if you don't want to manage servers, OpenPanel offers a cloud option with a free tier \u2014 something Ackee doesn't provide.",
|
||||
"If you appreciate Ackee's self-hosted simplicity but need mobile app analytics through native SDKs, deeper insights into user behavior, or the option to use a managed cloud service, OpenPanel is the natural upgrade path."
|
||||
]
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Ackee: Which is right for you?",
|
||||
"intro": "Both are privacy-focused open source analytics tools. Ackee focuses on simple web metrics with anonymous tracking. OpenPanel adds product analytics capabilities with user identification.",
|
||||
"one_liner": "Ackee is ultra-minimal web analytics; OpenPanel adds product analytics features like funnels, retention, and user identification.",
|
||||
"best_for_openpanel": [
|
||||
"Teams needing product analytics (funnels, retention, cohorts)",
|
||||
"Apps requiring user identification and tracking",
|
||||
"Projects that need to scale with ClickHouse architecture",
|
||||
"Mobile app analytics with native iOS, Android, React Native SDKs"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Personal blogs and small sites wanting simple stats",
|
||||
"Privacy maximalists who want zero user identification",
|
||||
"Developers comfortable with GraphQL API customization",
|
||||
"Sites that can run on MongoDB for analytics"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Ackee compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Analytics Depth",
|
||||
"openpanel": "Web + Product Analytics",
|
||||
"competitor": "Web Analytics Only",
|
||||
"notes": "OpenPanel combines web analytics with product analytics - funnels, retention, cohorts, and user profiles. Ackee provides essential web metrics like page views, referrers, and durations."
|
||||
},
|
||||
{
|
||||
"label": "User Identification",
|
||||
"openpanel": "Yes - Track individual users",
|
||||
"competitor": "No - Anonymous aggregate only",
|
||||
"notes": "OpenPanel lets you identify and track individual users across sessions. Ackee intentionally anonymizes all data and cannot track returning users for individual pages."
|
||||
},
|
||||
{
|
||||
"label": "Database Architecture",
|
||||
"openpanel": "ClickHouse (optimized for analytics)",
|
||||
"competitor": "MongoDB (general purpose)",
|
||||
"notes": "OpenPanel uses ClickHouse, purpose-built for analytical queries. Ackee uses MongoDB, which works well for small sites but may struggle with high-volume analytics workloads."
|
||||
},
|
||||
{
|
||||
"label": "Deployment Options",
|
||||
"openpanel": "Cloud + Self-hosted",
|
||||
"competitor": "Self-hosted only",
|
||||
"notes": "OpenPanel offers both managed cloud and self-hosted options. Ackee is self-hosted only - you must run your own server and MongoDB instance."
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes (MIT License)",
|
||||
"competitor": "Yes (MIT License)",
|
||||
"notes": "Both platforms are fully open source under the MIT license, allowing complete code inspection and modification."
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "Both are open source and privacy-focused, but with different capabilities and depth.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Web Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Page Views",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Referrers",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Visit Duration",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee tracks duration by updating records constantly"
|
||||
},
|
||||
{
|
||||
"name": "Device & Browser",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee requires 'detailed' mode for full device info"
|
||||
},
|
||||
{
|
||||
"name": "Screen Sizes",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee requires 'detailed' mode enabled"
|
||||
},
|
||||
{
|
||||
"name": "Real-Time Dashboard",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee shows active visitors in real-time"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Custom Event Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee supports custom events with key-value data"
|
||||
},
|
||||
{
|
||||
"name": "Funnel Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee has no funnel visualization or analysis"
|
||||
},
|
||||
{
|
||||
"name": "Retention Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee cannot track returning users by design"
|
||||
},
|
||||
{
|
||||
"name": "User Profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee intentionally anonymizes all user data"
|
||||
},
|
||||
{
|
||||
"name": "Cohort Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee's anonymous model doesn't support cohorts"
|
||||
},
|
||||
{
|
||||
"name": "User Path Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee cannot reconstruct user browsing history"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced Features",
|
||||
"features": [
|
||||
{
|
||||
"name": "A/B Testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee is analytics-only, no experimentation"
|
||||
},
|
||||
{
|
||||
"name": "GraphQL API",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Ackee's entire UI is powered by its GraphQL API"
|
||||
},
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel uses REST; Ackee uses GraphQL only"
|
||||
},
|
||||
{
|
||||
"name": "Email Reports",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee has no built-in reporting - requires custom tools"
|
||||
},
|
||||
{
|
||||
"name": "Multiple Domains",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support unlimited domains from one instance"
|
||||
},
|
||||
{
|
||||
"name": "Ignore Own Visits",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee can ignore logged-in dashboard visits"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & Compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Cookie-Free by Default",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both are cookieless by default"
|
||||
},
|
||||
{
|
||||
"name": "No Consent Banner Required",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both claim no consent needed for basic analytics"
|
||||
},
|
||||
{
|
||||
"name": "GDPR Compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee uses daily-rotating salt hashes for anonymization"
|
||||
},
|
||||
{
|
||||
"name": "Data Anonymization",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee uses multi-step anonymization with daily salt rotation"
|
||||
},
|
||||
{
|
||||
"name": "Self-Hosted Option",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee is self-hosted ONLY; OpenPanel offers both"
|
||||
},
|
||||
{
|
||||
"name": "EU Data Residency",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both can be hosted anywhere; Ackee requires self-hosting"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & SDKs",
|
||||
"features": [
|
||||
{
|
||||
"name": "JavaScript SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "ackee-tracker available via npm or self-served"
|
||||
},
|
||||
{
|
||||
"name": "React Integration",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "use-ackee React hook available"
|
||||
},
|
||||
{
|
||||
"name": "Vue/Nuxt Integration",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "nuxt-ackee module available"
|
||||
},
|
||||
{
|
||||
"name": "Gatsby Plugin",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "gatsby-plugin-ackee-tracker available"
|
||||
},
|
||||
{
|
||||
"name": "Native iOS SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee is web-focused only"
|
||||
},
|
||||
{
|
||||
"name": "Native Android SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee has no official mobile SDKs"
|
||||
},
|
||||
{
|
||||
"name": "WordPress Plugin",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Soapberry plugin available for WordPress"
|
||||
},
|
||||
{
|
||||
"name": "Flutter/Dart SDK",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "ackee_dart community package available"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK Size",
|
||||
"openpanel": "2.3 KB (gzipped)",
|
||||
"competitor": "~6 KB (gzipped), served from your Ackee instance",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Platforms",
|
||||
"openpanel": [
|
||||
"JavaScript/TypeScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"PHP",
|
||||
"Go",
|
||||
"Rust"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript (browser)",
|
||||
"React (use-ackee)",
|
||||
"Nuxt.js",
|
||||
"Vue",
|
||||
"Gatsby",
|
||||
"Svelte",
|
||||
"Angular",
|
||||
"WordPress",
|
||||
"Dart/Flutter"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes - MIT License",
|
||||
"competitor": "Yes - MIT License",
|
||||
"notes": "Both are MIT licensed and fully open source"
|
||||
},
|
||||
{
|
||||
"label": "Self Hosting",
|
||||
"openpanel": "Docker (simple single-container setup)",
|
||||
"competitor": "Docker with MongoDB required, or deploy to Vercel/Netlify/Heroku with MongoDB Atlas",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse (columnar, analytics-optimized)",
|
||||
"competitor": "MongoDB (document database)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data Retention",
|
||||
"openpanel": "Unlimited (self-hosted), configurable (cloud)",
|
||||
"competitor": "Unlimited (depends on your MongoDB storage)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Language",
|
||||
"openpanel": "TypeScript/Node.js",
|
||||
"competitor": "Node.js",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "API",
|
||||
"openpanel": "REST API",
|
||||
"competitor": "GraphQL API",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Both offer free self-hosting. OpenPanel also offers managed cloud options.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Simple pricing with 10,000 free events per month. Self-host for free with unlimited events. All features included at every tier."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "100% Free (self-hosted only)",
|
||||
"description": "Completely free - no paid tiers exist. You pay only for your own infrastructure (MongoDB and server hosting). MongoDB Atlas free tier (512MB) works for small sites.",
|
||||
"free_tier": "Self-hosted: Free forever, no paid option",
|
||||
"pricing_url": null
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Ackee to OpenPanel",
|
||||
"intro": "Switching from Ackee to OpenPanel is straightforward. Both are lightweight with similar integration approaches.",
|
||||
"difficulty": "easy",
|
||||
"estimated_time": "30 minutes to 2 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK to your application. Replace the ackee-tracker script with OpenPanel's lightweight tracking code."
|
||||
},
|
||||
{
|
||||
"title": "Map Events to OpenPanel",
|
||||
"description": "Ackee events translate to OpenPanel events. Replace instance.action() calls with op.track() - the structure is similar."
|
||||
},
|
||||
{
|
||||
"title": "Add User Identification (New)",
|
||||
"description": "Unlike Ackee, OpenPanel can identify users. Add op.identify() calls to unlock retention, cohorts, and user profiles - a major upgrade from Ackee's anonymous-only model."
|
||||
},
|
||||
{
|
||||
"title": "Configure Dashboards",
|
||||
"description": "Set up your OpenPanel dashboards. You'll now have access to funnels, retention charts, and cohort analysis that weren't possible in Ackee."
|
||||
},
|
||||
{
|
||||
"title": "Remove Ackee",
|
||||
"description": "Once verified, remove the Ackee tracker script and optionally decommission your Ackee server and MongoDB instance."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use simple event tracking APIs. Ackee's instance.action() becomes OpenPanel's op.track()."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Ackee intentionally anonymizes data, making historical import challenging. Most teams start fresh with OpenPanel."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Ackee",
|
||||
"intro": "Choose OpenPanel when you need more than simple web stats - funnels, retention, user identification, and scalability.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Scaling Beyond MongoDB",
|
||||
"description": "Ackee uses MongoDB which works for small sites but isn't optimized for analytical queries at scale. OpenPanel's ClickHouse backend is purpose-built for analytics workloads.",
|
||||
"icon": "trending-up"
|
||||
},
|
||||
{
|
||||
"title": "Teams Needing Product Analytics",
|
||||
"description": "Ackee provides basic web metrics. If you need funnels, retention analysis, cohort breakdowns, or user journey mapping, OpenPanel adds these capabilities while maintaining privacy.",
|
||||
"icon": "bar-chart"
|
||||
},
|
||||
{
|
||||
"title": "Wanting a Cloud Option",
|
||||
"description": "Ackee is self-hosted only with no managed cloud option. If you want analytics without managing servers, MongoDB, and backups, OpenPanel offers a free cloud tier.",
|
||||
"icon": "cloud"
|
||||
},
|
||||
{
|
||||
"title": "Apps Requiring User Identification",
|
||||
"description": "Ackee intentionally cannot identify users or track returning visitors. If you need to understand individual user behavior or measure retention, OpenPanel provides these features.",
|
||||
"icon": "user-check"
|
||||
},
|
||||
{
|
||||
"title": "Mobile App Analytics",
|
||||
"description": "Ackee is designed for websites with no official mobile SDKs. OpenPanel offers native SDKs for iOS, Android, and React Native with full product analytics capabilities.",
|
||||
"icon": "smartphone"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Ackee to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is OpenPanel as privacy-friendly as Ackee?",
|
||||
"answer": "Yes! Both are cookie-free by default and can be GDPR compliant without consent banners. The key difference is that OpenPanel allows optional user identification for product analytics, while Ackee is strictly anonymous. You control whether to identify users in OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Why switch from Ackee to OpenPanel?",
|
||||
"answer": "Teams typically switch when they need: product analytics (funnels, retention, cohorts), user identification, a managed cloud option, mobile SDKs, or a database optimized for analytics. Ackee excels at simple, anonymous web stats but lacks these capabilities."
|
||||
},
|
||||
{
|
||||
"question": "Is Ackee really free?",
|
||||
"answer": "Yes, Ackee itself is 100% free and open source. However, you must pay for your own hosting - a server to run Ackee and MongoDB. Using free tiers (Vercel + MongoDB Atlas 512MB), you can run Ackee at zero cost for small sites."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have a GraphQL API like Ackee?",
|
||||
"answer": "No, OpenPanel uses a REST API while Ackee uses GraphQL. Both approaches allow building custom tools and integrations. Ackee's entire UI is powered by its GraphQL API, making it very extensible."
|
||||
},
|
||||
{
|
||||
"question": "Can Ackee track mobile apps?",
|
||||
"answer": "Not officially. Ackee is designed for web analytics. There's a community Dart/Flutter package, but no official iOS or Android SDKs. OpenPanel offers native mobile SDKs with full product analytics."
|
||||
},
|
||||
{
|
||||
"question": "What will I lose switching from Ackee?",
|
||||
"answer": "Ackee's GraphQL API (OpenPanel uses REST), the ability to host on platforms like Vercel/Netlify as serverless functions, and the community plugins like Svelte and Angular integrations. You'll gain product analytics, user identification, and a managed cloud option."
|
||||
},
|
||||
{
|
||||
"question": "How does the database compare?",
|
||||
"answer": "Ackee uses MongoDB (general-purpose document database) while OpenPanel uses ClickHouse (columnar database optimized for analytics). ClickHouse is significantly faster for analytical queries, especially at scale."
|
||||
},
|
||||
{
|
||||
"question": "Can Ackee track unique visitors?",
|
||||
"answer": "Yes, but with limitations. Ackee uses a daily-rotating salt hash (IP + user-agent + domain + salt) to identify visitors within a single day. It cannot track returning visitors across days or track unique views per page - this is by design for privacy."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"guides": [
|
||||
{
|
||||
"title": "Website analytics setup",
|
||||
"url": "/guides/website-analytics-setup"
|
||||
},
|
||||
{
|
||||
"title": "Node.js analytics setup",
|
||||
"url": "/guides/nodejs-analytics"
|
||||
},
|
||||
{
|
||||
"title": "Migrate from Google Analytics",
|
||||
"url": "/guides/migrate-from-google-analytics"
|
||||
}
|
||||
],
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
},
|
||||
{
|
||||
"title": "Self-hosted web analytics",
|
||||
"url": "/articles/self-hosted-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Umami",
|
||||
"url": "/compare/umami-alternative"
|
||||
},
|
||||
{
|
||||
"name": "GoatCounter",
|
||||
"url": "/compare/goatcounter-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||