77 Commits

Author SHA1 Message Date
31fbe0a809 fix(ci):increase build limits preventing heap OOM
Some checks failed
Build and Push API / build-api (pull_request) Successful in 6m55s
Build and Push Worker / build-worker (pull_request) Has been cancelled
Build and Push Dashboard / build-dashboard (pull_request) Successful in 2h6m6s
Build and Push API / build-api (push) Successful in 8m40s
Build and Push Worker / build-worker (push) Successful in 7m23s
Build and Push Dashboard / build-dashboard (push) Failing after 1h53m54s
2026-04-01 11:27:08 +02:00
1b23fee108 fix(ci):overhaul the dash build
Some checks failed
Build and Push API / build-api (push) Successful in 8m11s
Build and Push Dashboard / build-dashboard (push) Failing after 37m52s
Build and Push Worker / build-worker (push) Successful in 8m25s
Build and Push API / build-api (pull_request) Successful in 7m13s
Build and Push Dashboard / build-dashboard (pull_request) Failing after 34m41s
Build and Push Worker / build-worker (pull_request) Successful in 6m52s
2026-04-01 09:40:25 +02:00
3043a9cdd1 fix(ci):deployments need pnpm in base image
Some checks failed
Build and Push API / build-api (push) Successful in 9m9s
Build and Push Dashboard / build-dashboard (push) Failing after 48m51s
Build and Push Worker / build-worker (push) Successful in 8m32s
Build and Push API / build-api (pull_request) Successful in 6m39s
Build and Push Dashboard / build-dashboard (pull_request) Failing after 48m22s
Build and Push Worker / build-worker (pull_request) Successful in 6m7s
2026-03-31 20:02:51 +02:00
655ea1f87e feat:add otel logging 2026-03-31 16:45:05 +02:00
fcb4cf5fb0 ci:add deployments
Some checks failed
Build and Push API / build-api (push) Failing after 44m22s
Build and Push Dashboard / build-dashboard (push) Has been cancelled
Build and Push Worker / build-worker (push) Has been cancelled
2026-03-31 15:54:58 +02:00
9b197abcfa chore:little fixes and formating and linting and patches 2026-03-31 15:50:54 +02:00
Carl-Gerhard Lindesvärd
a1ce71ffb6 fix:buffers
* wip

* remove active visitor counter in redis

* test

* fix profiel query

* fix
2026-03-24 13:54:00 +01:00
Carl-Gerhard Lindesvärd
20665789e1 fix: improve performance for realtime map 2026-03-23 22:35:04 +01:00
Carl-Gerhard Lindesvärd
2fb993fae5 public: updates of content 2026-03-23 14:59:06 +01:00
Carl-Gerhard Lindesvärd
b467a6ce7f bump(sdk): react-native 1.4.0 2026-03-23 10:53:54 +01:00
Carl-Gerhard Lindesvärd
b88b2844b3 docs: add section about offline mode 2026-03-23 10:53:54 +01:00
Carl-Gerhard Lindesvärd
ddc1b75b58 docs: add section how to scale ingestion for openpanel 2026-03-23 10:53:54 +01:00
Carl-Gerhard Lindesvärd
7239c59342 chore: update biome and publish script 2026-03-23 10:53:53 +01:00
Carl-Gerhard Lindesvärd
a82069c28c feat(sdk): add offline mode to the react-native SDK 2026-03-23 10:21:55 +01:00
Carl-Gerhard Lindesvärd
bca07ae0d7 docs: update api docs about groups 2026-03-23 09:23:05 +01:00
Carl-Gerhard Lindesvärd
21e51daa5f fix: lookup group members based on profiles table instead of events 2026-03-22 20:50:50 +01:00
Carl-Gerhard Lindesvärd
729722bf85 fix: potential fix for #301
wip
2026-03-21 13:12:54 +01:00
Carl-Gerhard Lindesvärd
a8481a213f fix: lock 2026-03-20 11:18:16 +01:00
Carl-Gerhard Lindesvärd
6287cb7958 fix: default groups when adding sessions 2026-03-20 11:12:32 +01:00
Carl-Gerhard Lindesvärd
ebc07e3a16 bump: sdk 2026-03-20 11:05:14 +01:00
Carl-Gerhard Lindesvärd
11e9ecac1a feat: group analytics
* wip

* wip

* wip

* wip

* wip

* add buffer

* wip

* wip

* fixes

* fix

* wip

* group validation

* fix group issues

* docs: add groups
2026-03-20 10:46:09 +01:00
Carl-Gerhard Lindesvärd
88a2d876ce fix: realtime improvements 2026-03-20 09:52:29 +01:00
Carl-Gerhard Lindesvärd
d1b39c4c93 fix: funnel on profile id
This will break mixed profile_id (anon + identified) but its worth it because its "correct". This will also be fixed when we have enabled backfill profile id on a session
2026-03-18 21:04:45 +01:00
Carl-Gerhard Lindesvärd
33431510b4 public: seo 2026-03-17 13:12:47 +01:00
Carl-Gerhard Lindesvärd
5557db83a6 fix: add filters for sessions table 2026-03-16 13:31:48 +01:00
Carl-Gerhard Lindesvärd
eab33d3127 fix: make table rows clickable 2026-03-16 13:30:34 +01:00
Carl-Gerhard Lindesvärd
4483e464d1 fix: optimize event buffer (#278)
* fix: how we fetch profiles in the buffer

* perf: optimize event buffer

* remove unused file

* fix

* wip

* wip: try groupmq 2

* try simplified event buffer with duration calculation on the fly instead
2026-03-16 13:29:40 +01:00
Carl-Gerhard Lindesvärd
4736f8509d fix: healthz readiness should only fail if redis fails 2026-03-11 13:53:11 +01:00
Carl-Gerhard Lindesvärd
05cf6bb39f fix: add search for Opportunities and Cannibalization 2026-03-11 11:30:19 +01:00
Carl-Gerhard Lindesvärd
6e1daf2c76 fix: ensure we have envs for gsc sync 2026-03-11 09:50:12 +01:00
Carl-Gerhard Lindesvärd
f2aa0273e6 debug gsc sync 2026-03-11 08:20:04 +01:00
Carl-Gerhard Lindesvärd
1b898660ad fix: improve landing page 2026-03-10 22:30:31 +01:00
Carl-Gerhard Lindesvärd
9836f75e17 fix: add gsc worker to bullboard 2026-03-09 21:42:20 +01:00
Carl-Gerhard Lindesvärd
271d189ed0 feat: added google search console 2026-03-09 20:47:02 +01:00
Carl-Gerhard Lindesvärd
70ca44f039 chore(public): update @opennextjs/cloudflare #309 2026-03-06 13:13:59 +01:00
Carl-Gerhard Lindesvärd
00f6cd6f50 fix: importer 2.. 2026-03-03 23:54:53 +01:00
Carl-Gerhard Lindesvärd
227d629dc5 fix: pnpm lock 2026-03-03 23:15:34 +01:00
Carl-Gerhard Lindesvärd
f2e19093f0 fix: importer.. 2026-03-03 22:17:49 +01:00
Carl-Gerhard Lindesvärd
7f85b2ac0a fix: pagination bug #296 2026-03-03 12:53:11 +01:00
Carl-Gerhard Lindesvärd
38965387da chore: add create checkout link 2026-03-03 12:52:57 +01:00
Carl-Gerhard Lindesvärd
74bcb7ead2 fix(api): improve export api, properties to be a comma seperated list 2026-03-03 11:37:05 +01:00
Carl-Gerhard Lindesvärd
2377f95b86 feat(dashboard): allow create organizations 2026-03-03 11:11:59 +01:00
Carl-Gerhard Lindesvärd
de6ca96628 chore: update gitignore 2026-03-03 11:04:20 +01:00
Carl-Gerhard Lindesvärd
9e46099246 chore: add dpa, update terms and privacy 2026-03-03 10:59:45 +01:00
Carl-Gerhard Lindesvärd
83761638f2 fix: improve how previous state is shown for funnels 2026-03-02 15:28:28 +01:00
Carl-Gerhard Lindesvärd
885f7225db bump(sdk): 1.2.0 2026-03-02 13:43:32 +01:00
Carl-Gerhard Lindesvärd
553e4cf675 fix: ts issues 2026-03-02 13:18:34 +01:00
Carl-Gerhard Lindesvärd
f2c414b4b4 fix(sdk): add timestamp when queueing events 2026-03-02 13:16:55 +01:00
Carl-Gerhard Lindesvärd
043730444a feat: improve how disabled works for the SDKS (to improve consent management) 2026-03-02 11:00:20 +01:00
Carl-Gerhard Lindesvärd
8c377c2066 fix: default last/first seen broken when clickhouse defaults to 1970 2026-03-02 09:34:23 +01:00
Carl-Gerhard Lindesvärd
647ac2a4af fix: redo how the importer works 2026-03-01 21:59:12 +01:00
Carl-Gerhard Lindesvärd
6251d143d1 fix(dashboard): pagination and login 2026-03-01 13:33:55 +01:00
Carl-Gerhard Lindesvärd
b801d6a8ef fix: last auth provider cookie (wrong domain) 2026-02-27 23:41:38 +01:00
Carl-Gerhard Lindesvärd
1272466235 feat: add tracking code on project settings 2026-02-27 23:27:13 +01:00
Carl-Gerhard Lindesvärd
2501ee1eef chore: remove unused var 2026-02-27 23:25:45 +01:00
Carl-Gerhard Lindesvärd
10da7d3a1d fix: improve onboarding 2026-02-27 22:45:21 +01:00
Carl-Gerhard Lindesvärd
b0aa7f4196 fix: reduce noise for api errors 2026-02-27 20:20:16 +01:00
Carl-Gerhard Lindesvärd
f4602f8e56 fix: add session end event for notification funnel 2026-02-27 18:37:37 +01:00
Carl-Gerhard Lindesvärd
efb50fafdb docs: add dashboard guides 2026-02-27 13:47:59 +01:00
Carl-Gerhard Lindesvärd
cd112237e9 docs: session replay 2026-02-27 11:22:12 +01:00
Carl-Gerhard Lindesvärd
9c6c7bb037 fix: funnel notifications 2026-02-27 10:24:45 +01:00
Carl-Gerhard Lindesvärd
928c44ef6a fix: duplicate session start (race condition) + remove old device id handling 2026-02-27 09:56:51 +01:00
Carl-Gerhard Lindesvärd
a42adcdbfb fix: broken add notifications rule 2026-02-27 09:37:43 +01:00
Carl-Gerhard Lindesvärd
8b18b86deb fix: invalidate queries better 2026-02-27 09:37:29 +01:00
Carl-Gerhard Lindesvärd
8db5905fb5 public: sitemap 2026-02-26 21:59:16 +01:00
Carl-Gerhard Lindesvärd
4b150dd987 public: custom homepage tracking 2026-02-26 21:34:00 +01:00
Carl-Gerhard Lindesvärd
baedf4343b chore: enable session replay 2026-02-26 21:34:00 +01:00
Martin
22fb4acf12 fix: Add SELF_HOSTED variable to .env.template (#288)
Without this env var I get the Trial expired screen after upgrading to version 2.
2026-02-26 20:58:56 +01:00
ericcapella
cb3f8016df fix: remove duplicate "Cookie-Free by Default" feature in posthog comparison (#293) 2026-02-26 20:58:15 +01:00
Kien Ngo
d9afeffbf1 fix: Allow copy value as object (#299) 2026-02-26 20:56:51 +01:00
Kien Ngo
4b9852b36f docs: Update react native Installation docs (#297) 2026-02-26 20:55:05 +01:00
Carl-Gerhard Lindesvärd
00e94ecb66 sdks: bump (session replays) 2026-02-26 19:43:21 +01:00
Carl-Gerhard Lindesvärd
3d84e4e77b fix: ensure we have a body before check type 2026-02-26 15:29:19 +01:00
Carl-Gerhard Lindesvärd
791668526c fix: migration for session replay 2026-02-26 15:11:36 +01:00
Carl-Gerhard Lindesvärd
02ddcf9a3d chore: github actions 2026-02-26 14:49:20 +01:00
Carl-Gerhard Lindesvärd
55bd5c4614 fix: lock file 2026-02-26 14:30:02 +01:00
Carl-Gerhard Lindesvärd
aa81bbfe77 feat: session replay
* wip

* wip

* wip

* wip

* final fixes

* comments

* fix
2026-02-26 14:09:53 +01:00
1070 changed files with 50325 additions and 16612 deletions

View File

@@ -1,5 +1,7 @@
# CLAUDE.md # 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. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview

View File

@@ -0,0 +1,55 @@
name: Build and Push API
on:
push:
branches: ["*"]
tags: ["v*"]
pull_request:
branches: [main]
env:
REGISTRY: git.zias.be
OWNER: zias
jobs:
build-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.OWNER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: apps/api/Dockerfile
target: runner
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api:buildcache
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-api:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
build-args: |-
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres

View File

@@ -0,0 +1,53 @@
name: Build and Push Dashboard
on:
push:
branches: ["*"]
tags: ["v*"]
pull_request:
branches: [main]
env:
REGISTRY: git.zias.be
OWNER: zias
jobs:
build-dashboard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.OWNER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: apps/start/Dockerfile
target: runner
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard:buildcache
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-dashboard:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}

View File

@@ -0,0 +1,55 @@
name: Build and Push Worker
on:
push:
branches: ["*"]
tags: ["v*"]
pull_request:
branches: [main]
env:
REGISTRY: git.zias.be
OWNER: zias
jobs:
build-worker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.OWNER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: apps/worker/Dockerfile
target: runner
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker:buildcache
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-worker:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
build-args: |-
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres

View File

@@ -3,55 +3,37 @@ name: Docker Build and Push
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
# branches: [ "main" ] paths-ignore:
paths: # README and docs
- "apps/api/**" - "**/README*"
- "apps/worker/**" - "**/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/**" - "apps/public/**"
- "apps/start/**" # Dev / tooling
- "packages/**" - "**/.vscode/**"
- "!packages/sdks/**" - "**/.cursor/**"
- "**Dockerfile" - "**/.env.example"
- ".github/workflows/**" - "**/.env.*.example"
- "**/.gitignore"
- "**/.eslintignore"
- "**/.prettierignore"
env: env:
repo_owner: "openpanel-dev" repo_owner: "openpanel-dev"
jobs: 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: 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 runs-on: ubuntu-latest
services: services:
redis: redis:
@@ -106,8 +88,7 @@ jobs:
permissions: permissions:
packages: write packages: write
contents: write contents: write
needs: [changes, lint-and-test] needs: lint-and-test
if: ${{ needs.changes.outputs.api == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -167,8 +148,7 @@ jobs:
permissions: permissions:
packages: write packages: write
contents: write contents: write
needs: [changes, lint-and-test] needs: lint-and-test
if: ${{ needs.changes.outputs.worker == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -228,8 +208,7 @@ jobs:
permissions: permissions:
packages: write packages: write
contents: write contents: write
needs: [changes, lint-and-test] needs: lint-and-test
if: ${{ needs.changes.outputs.dashboard == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.secrets .secrets
packages/db/src/generated/prisma packages/db/src/generated/prisma
packages/db/code-migrations/*.sql packages/db/code-migrations/*.sql
**/.open-next
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt packages/sdk/profileId.txt

View File

@@ -28,6 +28,7 @@ Openpanel is an open-source web and product analytics platform that combines the
## ✨ Features ## ✨ Features
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history - **🔍 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 - **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns - **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts - **🔔 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 | ✅ | ✅ | ❌ | ✅ | | 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** | | 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ | | 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ | | 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ | | 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ | | 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -59,6 +61,7 @@ Openpanel is an open-source web and product analytics platform that combines the
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access. > ✅* 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 faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅*** Plausible has simple goals > ✅*** Plausible has simple goals
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## Stack ## Stack

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ FROM node:${NODE_VERSION}-slim AS base
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612) # FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
ENV COREPACK_INTEGRITY_KEYS=0 ENV COREPACK_INTEGRITY_KEYS=0
RUN corepack enable && apt-get update && \ RUN rm -f /usr/local/bin/pnpm /usr/local/bin/pnpx && npm install -g pnpm@10.6.2 && apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
openssl \ openssl \

View File

@@ -30,7 +30,6 @@
"@openpanel/logger": "workspace:*", "@openpanel/logger": "workspace:*",
"@openpanel/payments": "workspace:*", "@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*", "@openpanel/queue": "workspace:*",
"groupmq": "catalog:",
"@openpanel/redis": "workspace:*", "@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*", "@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*", "@openpanel/validation": "workspace:*",
@@ -40,6 +39,7 @@
"fastify": "^5.6.1", "fastify": "^5.6.1",
"fastify-metrics": "^12.1.0", "fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0", "fastify-raw-body": "^5.0.0",
"groupmq": "catalog:",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { cacheable, cacheableLru } from '@openpanel/redis'; import { cacheable } from '@openpanel/redis';
import bots from './bots'; import bots from './bots';
// Pre-compile regex patterns at module load time // Pre-compile regex patterns at module load time
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot); const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
const includesBots = compiledBots.filter((bot) => 'includes' in bot); const includesBots = compiledBots.filter((bot) => 'includes' in bot);
export const isBot = cacheableLru( export const isBot = cacheable(
'is-bot', 'is-bot',
(ua: string) => { (ua: string) => {
// Check simple string patterns first (fast) // Check simple string patterns first (fast)
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
return null; return null;
}, },
{ 60 * 5
maxSize: 1000,
ttl: 60 * 5,
},
); );

View File

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

View File

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

View File

@@ -1,26 +1,25 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { generateId, slug } from '@openpanel/common'; import { generateId, slug } from '@openpanel/common';
import { parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { DeprecatedPostEventPayload } from '@openpanel/validation'; import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getStringHeaders, getTimestamp } from './track.controller'; import { getStringHeaders, getTimestamp } from './track.controller';
import { getDeviceId } from '@/utils/ids';
export async function postEvent( export async function postEvent(
request: FastifyRequest<{ request: FastifyRequest<{
Body: DeprecatedPostEventPayload; Body: DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const { timestamp, isTimestampFromThePast } = getTimestamp( const { timestamp, isTimestampFromThePast } = getTimestamp(
request.timestamp, request.timestamp,
request.body, request.body
); );
const ip = request.clientIp; 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 projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);
@@ -30,34 +29,22 @@ export async function postEvent(
} }
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua const { deviceId, sessionId } = await getDeviceId({
? generateDeviceId({ projectId,
salt: salts.current, ip,
origin: projectId, ua,
ip, salts,
ua, });
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
const uaInfo = parseUserAgent(ua, request.body?.properties); const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? request.body?.profileId ? `${projectId}:${request.body?.profileId ?? generateId()}`
? `${projectId}:${request.body?.profileId}` : deviceId;
: `${projectId}:${generateId()}`
: currentDeviceId;
const jobId = [ const jobId = [
slug(request.body.name), slug(request.body.name),
timestamp, timestamp,
projectId, projectId,
currentDeviceId, deviceId,
groupId, groupId,
] ]
.filter(Boolean) .filter(Boolean)
@@ -74,8 +61,8 @@ export async function postEvent(
}, },
uaInfo, uaInfo,
geo, geo,
currentDeviceId, deviceId,
previousDeviceId, sessionId: sessionId ?? '',
}, },
groupId, groupId,
jobId, jobId,

View File

@@ -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 { DateTime } from '@openpanel/common';
import type { GetEventListOptions } from '@openpanel/db'; import type { GetEventListOptions } from '@openpanel/db';
import { import {
ChartEngine,
ClientType, ClientType,
db, db,
getEventList, getEventList,
getEventsCountCached, getEventsCount,
getSettingsForProject, getSettingsForProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zReport } from '@openpanel/validation'; import { zChartEvent, zReport } from '@openpanel/validation';
import { omit } from 'ramda'; 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( async function getProjectId(
request: FastifyRequest<{ request: FastifyRequest<{
@@ -22,8 +20,7 @@ async function getProjectId(
project_id?: string; project_id?: string;
projectId?: string; projectId?: string;
}; };
}>, }>
reply: FastifyReply,
) { ) {
let projectId = request.query.projectId || request.query.project_id; let projectId = request.query.projectId || request.query.project_id;
@@ -74,10 +71,22 @@ const eventsScheme = z.object({
page: z.coerce.number().optional().default(1), page: z.coerce.number().optional().default(1),
limit: z.coerce.number().optional().default(50), limit: z.coerce.number().optional().default(50),
includes: z includes: z
.preprocess( .preprocess((arg) => {
(arg) => (typeof arg === 'string' ? [arg] : arg), if (arg == null) {
z.array(z.string()), 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(), .optional(),
}); });
@@ -85,7 +94,7 @@ export async function events(
request: FastifyRequest<{ request: FastifyRequest<{
Querystring: z.infer<typeof eventsScheme>; Querystring: z.infer<typeof eventsScheme>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const query = eventsScheme.safeParse(request.query); 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 limit = query.data.limit;
const page = Math.max(query.data.page, 1); const page = Math.max(query.data.page, 1);
const take = Math.max(Math.min(limit, 1000), 1); const take = Math.max(Math.min(limit, 1000), 1);
@@ -118,20 +127,20 @@ export async function events(
meta: false, meta: false,
...query.data.includes?.reduce( ...query.data.includes?.reduce(
(acc, key) => ({ ...acc, [key]: true }), (acc, key) => ({ ...acc, [key]: true }),
{}, {}
), ),
}, },
}; };
const [data, totalCount] = await Promise.all([ const [data, totalCount] = await Promise.all([
getEventList(options), getEventList(options),
getEventsCountCached(omit(['cursor', 'take'], options)), getEventsCount(options),
]); ]);
reply.send({ reply.send({
meta: { meta: {
count: data.length, count: data.length,
totalCount: totalCount, totalCount,
pages: Math.ceil(totalCount / options.take), pages: Math.ceil(totalCount / options.take),
current: cursor + 1, current: cursor + 1,
}, },
@@ -158,7 +167,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(), filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(), segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(), property: zChartEvent.shape.property.optional(),
}), })
) )
.optional(), .optional(),
// Backward compatibility - events will be migrated to series via preprocessing // Backward compatibility - events will be migrated to series via preprocessing
@@ -169,7 +178,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(), filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(), segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(), property: zChartEvent.shape.property.optional(),
}), })
) )
.optional(), .optional(),
}); });
@@ -178,7 +187,7 @@ export async function charts(
request: FastifyRequest<{ request: FastifyRequest<{
Querystring: Record<string, string>; Querystring: Record<string, string>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const query = chartSchemeFull.safeParse(parseQueryString(request.query)); 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 { timezone } = await getSettingsForProject(projectId);
const { events, series, ...rest } = query.data; const { events, series, ...rest } = query.data;

View 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());
}

View File

@@ -1,12 +1,12 @@
import { isShuttingDown } from '@/utils/graceful-shutdown';
import { chQuery, db } from '@openpanel/db'; import { chQuery, db } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isShuttingDown } from '@/utils/graceful-shutdown';
// For docker compose healthcheck // For docker compose healthcheck
export async function healthcheck( export async function healthcheck(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
try { try {
const redisRes = await getRedisCache().ping(); const redisRes = await getRedisCache().ping();
@@ -21,6 +21,7 @@ export async function healthcheck(
ch: chRes && chRes.length > 0, ch: chRes && chRes.length > 0,
}); });
} catch (error) { } catch (error) {
request.log.warn('healthcheck failed', { error });
return reply.status(503).send({ return reply.status(503).send({
ready: false, ready: false,
reason: 'dependencies not ready', reason: 'dependencies not ready',
@@ -41,18 +42,22 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
// Perform lightweight dependency checks for readiness // Perform lightweight dependency checks for readiness
const redisRes = await getRedisCache().ping(); const redisRes = await getRedisCache().ping();
const dbRes = await db.project.findFirst(); const dbRes = await db.$executeRaw`SELECT 1`;
const chRes = await chQuery('SELECT 1'); const chRes = await chQuery('SELECT 1');
const isReady = redisRes && dbRes && chRes; const isReady = redisRes;
if (!isReady) { if (!isReady) {
return reply.status(503).send({ const res = {
ready: false,
reason: 'dependencies not ready',
redis: redisRes === 'PONG', redis: redisRes === 'PONG',
db: !!dbRes, db: !!dbRes,
ch: chRes && chRes.length > 0, ch: chRes && chRes.length > 0,
};
request.log.warn('dependencies not ready', res);
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
...res,
}); });
} }

View File

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

View File

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

View File

@@ -1,23 +1,10 @@
import type { FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket'; import type { WebSocket } from '@fastify/websocket';
import { import { eventBuffer } from '@openpanel/db';
eventBuffer,
getProfileById,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json'; import { setSuperJson } from '@openpanel/json';
import { import { subscribeToPublishedEvent } from '@openpanel/redis';
psubscribeToPublishedEvent,
subscribeToPublishedEvent,
} from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc'; import { getProjectAccess } from '@openpanel/trpc';
import { getOrganizationAccess } from '@openpanel/trpc/src/access'; import { getOrganizationAccess } from '@openpanel/trpc/src/access';
import type { FastifyRequest } from 'fastify';
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
}
export function wsVisitors( export function wsVisitors(
socket: WebSocket, socket: WebSocket,
@@ -25,32 +12,32 @@ export function wsVisitors(
Params: { Params: {
projectId: string; projectId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => { const sendCount = () => {
if (event?.projectId === params.projectId) { eventBuffer
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { .getActiveVisitorCount(params.projectId)
.then((count) => {
socket.send(String(count)); socket.send(String(count));
})
.catch(() => {
socket.send('0');
}); });
} };
});
const punsubscribe = psubscribeToPublishedEvent( const unsubscribe = subscribeToPublishedEvent(
'__keyevent@0__:expired', 'events',
(key) => { 'batch',
const [projectId] = getLiveEventInfo(key); ({ projectId }) => {
if (projectId && projectId === params.projectId) { if (projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { sendCount();
socket.send(String(count));
});
} }
}, }
); );
socket.on('close', () => { socket.on('close', () => {
unsubscribe(); unsubscribe();
punsubscribe();
}); });
} }
@@ -62,18 +49,10 @@ export async function wsProjectEvents(
}; };
Querystring: { Querystring: {
token?: string; token?: string;
type?: 'saved' | 'received';
}; };
}>, }>
) { ) {
const { params, query } = req; const { params } = req;
const type = query.type || 'saved';
if (!['saved', 'received'].includes(type)) {
socket.send('Invalid type');
socket.close();
return;
}
const userId = req.session?.userId; const userId = req.session?.userId;
if (!userId) { if (!userId) {
@@ -87,24 +66,20 @@ export async function wsProjectEvents(
projectId: params.projectId, projectId: params.projectId,
}); });
if (!access) {
socket.send('No access');
socket.close();
return;
}
const unsubscribe = subscribeToPublishedEvent( const unsubscribe = subscribeToPublishedEvent(
'events', 'events',
type, 'batch',
async (event) => { ({ projectId, count }) => {
if (event.projectId === params.projectId) { if (projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId); socket.send(setSuperJson({ count }));
socket.send(
superjson.stringify(
access
? {
...event,
profile,
}
: transformMinimalEvent(event),
),
);
} }
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());
@@ -116,7 +91,7 @@ export async function wsProjectNotifications(
Params: { Params: {
projectId: string; projectId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const userId = req.session?.userId; const userId = req.session?.userId;
@@ -143,9 +118,9 @@ export async function wsProjectNotifications(
'created', 'created',
(notification) => { (notification) => {
if (notification.projectId === params.projectId) { if (notification.projectId === params.projectId) {
socket.send(superjson.stringify(notification)); socket.send(setSuperJson(notification));
} }
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());
@@ -157,7 +132,7 @@ export async function wsOrganizationEvents(
Params: { Params: {
organizationId: string; organizationId: string;
}; };
}>, }>
) { ) {
const { params } = req; const { params } = req;
const userId = req.session?.userId; const userId = req.session?.userId;
@@ -184,7 +159,7 @@ export async function wsOrganizationEvents(
'subscription_updated', 'subscription_updated',
(message) => { (message) => {
socket.send(setSuperJson(message)); socket.send(setSuperJson(message));
}, }
); );
socket.on('close', () => unsubscribe()); socket.on('close', () => unsubscribe());

View File

@@ -0,0 +1,68 @@
import { parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import { type LogsQueuePayload, logsQueue } from '@openpanel/queue';
import { type ILogBatchPayload, zLogBatchPayload } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getDeviceId } from '@/utils/ids';
import { getStringHeaders } from './track.controller';
export async function handler(
request: FastifyRequest<{ Body: ILogBatchPayload }>,
reply: FastifyReply,
) {
const projectId = request.client?.projectId;
if (!projectId) {
return reply.status(400).send({ status: 400, error: 'Missing projectId' });
}
const validationResult = zLogBatchPayload.safeParse(request.body);
if (!validationResult.success) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Validation failed',
errors: validationResult.error.errors,
});
}
const { logs } = validationResult.data;
const ip = request.clientIp;
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const headers = getStringHeaders(request.headers);
const receivedAt = new Date().toISOString();
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
const { deviceId, sessionId } = await getDeviceId({ projectId, ip, ua, salts });
const uaInfo = parseUserAgent(ua, undefined);
const jobs: LogsQueuePayload[] = logs.map((log) => ({
type: 'incomingLog' as const,
payload: {
projectId,
log: {
...log,
timestamp: log.timestamp ?? receivedAt,
},
uaInfo,
geo: {
country: geo.country,
city: geo.city,
region: geo.region,
},
headers,
deviceId,
sessionId,
},
}));
await logsQueue.addBulk(
jobs.map((job) => ({
name: 'incomingLog',
data: job,
})),
);
return reply.status(200).send({ ok: true, count: logs.length });
}

View File

@@ -1,5 +1,4 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { HttpError } from '@/utils/errors';
import { stripTrailingSlash } from '@openpanel/common'; import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server'; import { hashPassword } from '@openpanel/common/server';
import { import {
@@ -10,6 +9,7 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { HttpError } from '@/utils/errors';
// Validation schemas // Validation schemas
const zCreateProject = z.object({ const zCreateProject = z.object({
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
// Projects CRUD // Projects CRUD
export async function listProjects( export async function listProjects(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
const projects = await db.project.findMany({ const projects = await db.project.findMany({
where: { where: {
@@ -74,7 +74,7 @@ export async function listProjects(
export async function getProject( export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const project = await db.project.findFirst({ const project = await db.project.findFirst({
where: { where: {
@@ -92,7 +92,7 @@ export async function getProject(
export async function createProject( export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateProject.safeParse(request.body); const parsed = zCreateProject.safeParse(request.body);
@@ -139,12 +139,9 @@ export async function createProject(
}, },
}); });
// Clear cache
await Promise.all([ await Promise.all([
getProjectByIdCached.clear(project.id), getProjectByIdCached.clear(project.id),
project.clients.map((client) => { ...project.clients.map((client) => getClientByIdCached.clear(client.id)),
getClientByIdCached.clear(client.id);
}),
]); ]);
reply.send({ reply.send({
@@ -165,7 +162,7 @@ export async function updateProject(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateProject>; Body: z.infer<typeof zUpdateProject>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateProject.safeParse(request.body); const parsed = zUpdateProject.safeParse(request.body);
@@ -223,12 +220,9 @@ export async function updateProject(
data: updateData, data: updateData,
}); });
// Clear cache
await Promise.all([ await Promise.all([
getProjectByIdCached.clear(project.id), getProjectByIdCached.clear(project.id),
existing.clients.map((client) => { ...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
getClientByIdCached.clear(client.id);
}),
]); ]);
reply.send({ data: project }); reply.send({ data: project });
@@ -236,7 +230,7 @@ export async function updateProject(
export async function deleteProject( export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const project = await db.project.findFirst({ const project = await db.project.findFirst({
where: { where: {
@@ -266,7 +260,7 @@ export async function deleteProject(
// Clients CRUD // Clients CRUD
export async function listClients( export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>, request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const where: any = { const where: any = {
organizationId: request.client!.organizationId, organizationId: request.client!.organizationId,
@@ -300,7 +294,7 @@ export async function listClients(
export async function getClient( export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const client = await db.client.findFirst({ const client = await db.client.findFirst({
where: { where: {
@@ -318,7 +312,7 @@ export async function getClient(
export async function createClient( export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateClient.safeParse(request.body); const parsed = zCreateClient.safeParse(request.body);
@@ -374,7 +368,7 @@ export async function updateClient(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateClient>; Body: z.infer<typeof zUpdateClient>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateClient.safeParse(request.body); const parsed = zUpdateClient.safeParse(request.body);
@@ -417,7 +411,7 @@ export async function updateClient(
export async function deleteClient( export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const client = await db.client.findFirst({ const client = await db.client.findFirst({
where: { where: {
@@ -444,7 +438,7 @@ export async function deleteClient(
// References CRUD // References CRUD
export async function listReferences( export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>, request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const where: any = {}; const where: any = {};
@@ -488,7 +482,7 @@ export async function listReferences(
export async function getReference( export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const reference = await db.reference.findUnique({ const reference = await db.reference.findUnique({
where: { where: {
@@ -516,7 +510,7 @@ export async function getReference(
export async function createReference( export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>, request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zCreateReference.safeParse(request.body); const parsed = zCreateReference.safeParse(request.body);
@@ -559,7 +553,7 @@ export async function updateReference(
Params: { id: string }; Params: { id: string };
Body: z.infer<typeof zUpdateReference>; Body: z.infer<typeof zUpdateReference>;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const parsed = zUpdateReference.safeParse(request.body); const parsed = zUpdateReference.safeParse(request.body);
@@ -616,7 +610,7 @@ export async function updateReference(
export async function deleteReference( export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>, request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const reference = await db.reference.findUnique({ const reference = await db.reference.findUnique({
where: { where: {

View File

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

View File

@@ -1,16 +1,17 @@
import { LogError } from '@/utils/errors';
import { import {
Arctic, Arctic,
type OAuth2Tokens,
createSession, createSession,
generateSessionToken, generateSessionToken,
github, github,
google, google,
type OAuth2Tokens,
setLastAuthProviderCookie,
setSessionTokenCookie, setSessionTokenCookie,
} from '@openpanel/auth'; } from '@openpanel/auth';
import { type Account, connectUserToOrganization, db } from '@openpanel/db'; import { type Account, connectUserToOrganization, db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { LogError } from '@/utils/errors';
async function getGithubEmail(githubAccessToken: string) { async function getGithubEmail(githubAccessToken: string) {
const emailListRequest = new Request('https://api.github.com/user/emails'); const emailListRequest = new Request('https://api.github.com/user/emails');
@@ -74,10 +75,14 @@ async function handleExistingUser({
setSessionTokenCookie( setSessionTokenCookie(
(...args) => reply.setCookie(...args), (...args) => reply.setCookie(...args),
sessionToken, sessionToken,
session.expiresAt, session.expiresAt
);
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
); );
return reply.redirect( 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, existingUser,
oauthUser, oauthUser,
providerName, providerName,
}, }
); );
} }
@@ -138,10 +143,14 @@ async function handleNewUser({
setSessionTokenCookie( setSessionTokenCookie(
(...args) => reply.setCookie(...args), (...args) => reply.setCookie(...args),
sessionToken, sessionToken,
session.expiresAt, session.expiresAt
);
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
); );
return reply.redirect( 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( async function validateOAuthCallback(
req: FastifyRequest, req: FastifyRequest,
provider: Provider, provider: Provider
): Promise<ValidatedOAuthQuery> { ): Promise<ValidatedOAuthQuery> {
const schema = z.object({ const schema = z.object({
code: z.string(), code: z.string(),
@@ -353,7 +362,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
function redirectWithError(reply: FastifyReply, error: LogError | unknown) { function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL( 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'; url.pathname = '/login';
if (error instanceof LogError) { if (error instanceof LogError) {

View File

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

View File

@@ -1,22 +1,33 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { HttpError } from '@/utils/errors';
import { generateId, slug } from '@openpanel/common'; import { generateId, slug } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { import {
getProfileById,
getSalts,
groupBuffer,
replayBuffer,
upsertProfile,
} from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import {
type EventsQueuePayloadIncomingEvent,
getEventsGroupQueueShard,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import {
type IAssignGroupPayload,
type IDecrementPayload, type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload, type IIdentifyPayload,
type IIncrementPayload, type IIncrementPayload,
type IReplayPayload,
type ITrackHandlerPayload, type ITrackHandlerPayload,
type ITrackPayload, type ITrackPayload,
zTrackHandlerPayload, zTrackHandlerPayload,
} from '@openpanel/validation'; } 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']) { export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries( return Object.entries(
@@ -28,14 +39,14 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
'openpanel-client-id', 'openpanel-client-id',
'request-id', 'request-id',
], ],
headers, headers
), )
).reduce( ).reduce(
(acc, [key, value]) => ({ (acc, [key, value]) => ({
...acc, ...acc,
[key]: value ? String(value) : undefined, [key]: value ? String(value) : undefined,
}), }),
{}, {}
); );
} }
@@ -45,14 +56,15 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
| IIdentifyPayload | IIdentifyPayload
| undefined; | undefined;
return ( if (identity) {
identity || return identity;
(body.payload.profileId }
? {
profileId: String(body.payload.profileId), return body.payload.profileId
} ? {
: undefined) profileId: String(body.payload.profileId),
); }
: undefined;
} }
return undefined; return undefined;
@@ -60,7 +72,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
export function getTimestamp( export function getTimestamp(
timestamp: FastifyRequest['timestamp'], timestamp: FastifyRequest['timestamp'],
payload: ITrackHandlerPayload['payload'], payload: ITrackHandlerPayload['payload']
) { ) {
const safeTimestamp = timestamp || Date.now(); const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp = const userDefinedTimestamp =
@@ -104,8 +116,9 @@ interface TrackContext {
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean }; timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload; identity?: IIdentifyPayload;
currentDeviceId?: string; deviceId: string;
previousDeviceId?: string; sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
geo: GeoLocation; geo: GeoLocation;
} }
@@ -113,7 +126,7 @@ async function buildContext(
request: FastifyRequest<{ request: FastifyRequest<{
Body: ITrackHandlerPayload; Body: ITrackHandlerPayload;
}>, }>,
validatedBody: ITrackHandlerPayload, validatedBody: ITrackHandlerPayload
): Promise<TrackContext> { ): Promise<TrackContext> {
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
if (!projectId) { if (!projectId) {
@@ -128,49 +141,29 @@ async function buildContext(
const ua = request.headers['user-agent'] ?? 'unknown/1.0'; const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);
const identity = getIdentity(validatedBody); const identity = getIdentity(validatedBody);
const profileId = identity?.profileId; const profileId = identity?.profileId;
// 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 && validatedBody.type === 'track') { if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId; validatedBody.payload.profileId = profileId;
} }
const overrideDeviceId =
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined;
// Get geo location (needed for track and identify) // Get geo location (needed for track and identify)
const geo = await getGeoLocation(ip); const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
// Generate device IDs if needed (for track) const deviceIdResult = await getDeviceId({
let currentDeviceId: string | undefined; projectId,
let previousDeviceId: string | undefined; ip,
ua,
if (validatedBody.type === 'track') { salts,
const overrideDeviceId = overrideDeviceId,
typeof validatedBody.payload.properties?.__deviceId === 'string' });
? validatedBody.payload.properties.__deviceId
: undefined;
const salts = await getSalts();
currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
}
return { return {
projectId, projectId,
@@ -182,46 +175,37 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast, isFromPast: timestamp.isTimestampFromThePast,
}, },
identity, identity,
currentDeviceId, deviceId: deviceIdResult.deviceId,
previousDeviceId, sessionId: deviceIdResult.sessionId,
session: deviceIdResult.session,
geo, geo,
}; };
} }
async function handleTrack( async function handleTrack(
payload: ITrackPayload, payload: ITrackPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
const { const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
projectId, context;
currentDeviceId,
previousDeviceId,
geo,
headers,
timestamp,
} = context;
if (!currentDeviceId || !previousDeviceId) {
throw new HttpError('Device ID generation failed', { status: 500 });
}
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? payload.profileId ? payload.profileId
? `${projectId}:${payload.profileId}` ? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}` : undefined
: currentDeviceId; : deviceId;
const jobId = [ const jobId = [
slug(payload.name), slug(payload.name),
timestamp.value, timestamp.value,
projectId, projectId,
currentDeviceId, deviceId,
groupId, groupId,
] ]
.filter(Boolean) .filter(Boolean)
.join('-'); .join('-');
const promises = []; const promises: Promise<unknown>[] = [];
// If we have more than one property in the identity object, we should identify the user // 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 // Otherwise its only a profileId and we should not identify the user
@@ -230,24 +214,26 @@ async function handleTrack(
} }
promises.push( promises.push(
getEventsGroupQueueShard(groupId).add({ getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value, orderMs: timestamp.value,
data: { data: {
projectId, projectId,
headers, headers,
event: { event: {
...payload, ...payload,
groups: payload.groups ?? [],
timestamp: timestamp.value, timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast, isTimestampFromThePast: timestamp.isFromPast,
}, },
uaInfo, uaInfo,
geo, geo,
currentDeviceId, deviceId,
previousDeviceId, sessionId,
session,
}, },
groupId, groupId,
jobId, jobId,
}), })
); );
await Promise.all(promises); await Promise.all(promises);
@@ -255,7 +241,7 @@ async function handleTrack(
async function handleIdentify( async function handleIdentify(
payload: IIdentifyPayload, payload: IIdentifyPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
const { projectId, geo, ua } = context; const { projectId, geo, ua } = context;
const uaInfo = parseUserAgent(ua, payload.properties); const uaInfo = parseUserAgent(ua, payload.properties);
@@ -285,7 +271,7 @@ async function handleIdentify(
async function adjustProfileProperty( async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload, payload: IIncrementPayload | IDecrementPayload,
projectId: string, projectId: string,
direction: 1 | -1, direction: 1 | -1
): Promise<void> { ): Promise<void> {
const { profileId, property, value } = payload; const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
@@ -295,7 +281,7 @@ async function adjustProfileProperty(
const parsed = Number.parseInt( const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties), pathOr<string>('0', property.split('.'), profile.properties),
10, 10
); );
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
@@ -305,7 +291,7 @@ async function adjustProfileProperty(
profile.properties = assocPath( profile.properties = assocPath(
property.split('.'), property.split('.'),
parsed + direction * (value || 1), parsed + direction * (value || 1),
profile.properties, profile.properties
); );
await upsertProfile({ await upsertProfile({
@@ -318,23 +304,74 @@ async function adjustProfileProperty(
async function handleIncrement( async function handleIncrement(
payload: IIncrementPayload, payload: IIncrementPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1); await adjustProfileProperty(payload, context.projectId, 1);
} }
async function handleDecrement( async function handleDecrement(
payload: IDecrementPayload, payload: IDecrementPayload,
context: TrackContext, context: TrackContext
): Promise<void> { ): Promise<void> {
await adjustProfileProperty(payload, context.projectId, -1); 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 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);
}
async function handleGroup(
payload: IGroupPayload,
context: TrackContext
): Promise<void> {
const { id, type, name, properties = {} } = payload;
await groupBuffer.add({
id,
projectId: context.projectId,
type,
name,
properties,
});
}
async function handleAssignGroup(
payload: IAssignGroupPayload,
context: TrackContext
): Promise<void> {
const profileId = payload.profileId ?? context.deviceId;
if (!profileId) {
return;
}
await upsertProfile({
id: String(profileId),
projectId: context.projectId,
isExternal: !!payload.profileId,
groups: payload.groupIds,
});
}
export async function handler( export async function handler(
request: FastifyRequest<{ request: FastifyRequest<{
Body: ITrackHandlerPayload; Body: ITrackHandlerPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
// Validate request body with Zod // Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body); const validationResult = zTrackHandlerPayload.safeParse(request.body);
@@ -375,6 +412,15 @@ export async function handler(
case 'decrement': case 'decrement':
await handleDecrement(validatedBody.payload, context); await handleDecrement(validatedBody.payload, context);
break; break;
case 'replay':
await handleReplay(validatedBody.payload, context);
break;
case 'group':
await handleGroup(validatedBody.payload, context);
break;
case 'assign_group':
await handleAssignGroup(validatedBody.payload, context);
break;
default: default:
return reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
@@ -383,12 +429,15 @@ export async function handler(
}); });
} }
reply.status(200).send(); reply.status(200).send({
deviceId: context.deviceId,
sessionId: context.sessionId,
});
} }
export async function fetchDeviceId( export async function fetchDeviceId(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
const salts = await getSalts(); const salts = await getSalts();
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
@@ -421,20 +470,31 @@ export async function fetchDeviceId(
try { try {
const multi = getRedisCache().multi(); const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`); multi.hget(
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`); `bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data'
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data'
);
const res = await multi.exec(); const res = await multi.exec();
if (res?.[0]?.[1]) { if (res?.[0]?.[1]) {
const data = JSON.parse(res?.[0]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({ return reply.status(200).send({
deviceId: currentDeviceId, deviceId: currentDeviceId,
sessionId,
message: 'current session exists for this device id', message: 'current session exists for this device id',
}); });
} }
if (res?.[1]?.[1]) { if (res?.[1]?.[1]) {
const data = JSON.parse(res?.[1]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({ return reply.status(200).send({
deviceId: previousDeviceId, deviceId: previousDeviceId,
sessionId,
message: 'previous session exists for this device id', message: 'previous session exists for this device id',
}); });
} }
@@ -444,6 +504,7 @@ export async function fetchDeviceId(
return reply.status(200).send({ return reply.status(200).send({
deviceId: currentDeviceId, deviceId: currentDeviceId,
sessionId: '',
message: 'No session exists for this device id', message: 'No session exists for this device id',
}); });
} }

View File

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

View File

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

View File

@@ -1,26 +1,28 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { import type {
DeprecatedPostEventPayload, DeprecatedPostEventPayload,
ITrackHandlerPayload, ITrackHandlerPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isDuplicatedEvent } from '@/utils/deduplicate';
export async function duplicateHook( export async function duplicateHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const ip = req.clientIp; const ip = req.clientIp;
const origin = req.headers.origin; const origin = req.headers.origin;
const clientId = req.headers['openpanel-client-id']; 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 const isDuplicate = shouldCheck
? await isDuplicatedEvent({ ? await isDuplicatedEvent({
ip, ip,
origin, origin,
payload: req.body, payload: body,
projectId: clientId as string, projectId: clientId as string,
}) })
: false; : false;
@@ -29,3 +31,25 @@ export async function duplicateHook(
return reply.status(200).send('Duplicate event'); 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;
}

View File

@@ -1,20 +1,19 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db'; import { createBotEvent } from '@openpanel/db';
import type { import type {
DeprecatedPostEventPayload, DeprecatedPostEventPayload,
ITrackHandlerPayload, ITrackHandlerPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { isBot } from '@/bots';
export async function isBotHook( export async function isBotHook(
req: FastifyRequest<{ req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload; Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>, }>,
reply: FastifyReply, reply: FastifyReply
) { ) {
const bot = req.headers['user-agent'] const bot = req.headers['user-agent']
? isBot(req.headers['user-agent']) ? await isBot(req.headers['user-agent'])
: null; : null;
if (bot && req.client?.projectId) { if (bot && req.client?.projectId) {
@@ -44,6 +43,6 @@ export async function isBotHook(
} }
} }
return reply.status(202).send(); return reply.status(202).send({ bot });
} }
} }

View File

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

View File

@@ -1,4 +1,3 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda'; import { path, pick } from 'ramda';
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS']; const ignoreMethods = ['OPTIONS'];
const getTrpcInput = ( const getTrpcInput = (
request: FastifyRequest, request: FastifyRequest
): Record<string, unknown> | undefined => { ): Record<string, unknown> | undefined => {
const input = path<any>(['query', 'input'], request); const input = path<any>(['query', 'input'], request);
try { try {
@@ -18,7 +17,7 @@ const getTrpcInput = (
export async function requestLoggingHook( export async function requestLoggingHook(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply
) { ) {
if (ignoreMethods.includes(request.method)) { if (ignoreMethods.includes(request.method)) {
return; return;
@@ -40,9 +39,8 @@ export async function requestLoggingHook(
elapsed: reply.elapsedTime, elapsed: reply.elapsedTime,
headers: pick( headers: pick(
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'], ['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
request.headers, request.headers
), ),
body: request.body,
}); });
} }
} }

View File

@@ -1,14 +1,15 @@
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
process.env.TZ = 'UTC'; process.env.TZ = 'UTC';
import compress from '@fastify/compress'; import compress from '@fastify/compress';
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors'; import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; import {
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; decodeSessionToken,
import type { FastifyBaseLogger, FastifyRequest } from 'fastify'; EMPTY_SESSION,
import Fastify from 'fastify'; type SessionValidationResult,
import metricsPlugin from 'fastify-metrics'; validateSessionToken,
} from '@openpanel/auth';
import { generateId } from '@openpanel/common'; import { generateId } from '@openpanel/common';
import { import {
type IServiceClientWithProject, type IServiceClientWithProject,
@@ -17,13 +18,11 @@ import {
import { getRedisPub } from '@openpanel/redis'; import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc'; import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc'; import { appRouter, createContext } from '@openpanel/trpc';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
EMPTY_SESSION, import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
type SessionValidationResult, import Fastify from 'fastify';
decodeSessionToken, import metricsPlugin from 'fastify-metrics';
validateSessionToken,
} from '@openpanel/auth';
import sourceMapSupport from 'source-map-support'; import sourceMapSupport from 'source-map-support';
import { import {
healthcheck, healthcheck,
@@ -37,9 +36,11 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router'; import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router'; import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router'; import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router'; import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router'; import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router'; import liveRouter from './routes/live.router';
import logsRouter from './routes/logs.router';
import manageRouter from './routes/manage.router'; import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router'; import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router'; import oauthRouter from './routes/oauth-callback.router';
@@ -72,7 +73,7 @@ const startServer = async () => {
try { try {
const fastify = Fastify({ const fastify = Fastify({
maxParamLength: 15_000, maxParamLength: 15_000,
bodyLimit: 1048576 * 500, // 500MB bodyLimit: 1_048_576 * 500, // 500MB
loggerInstance: logger as unknown as FastifyBaseLogger, loggerInstance: logger as unknown as FastifyBaseLogger,
disableRequestLogging: true, disableRequestLogging: true,
genReqId: (req) => genReqId: (req) =>
@@ -84,7 +85,7 @@ const startServer = async () => {
fastify.register(cors, () => { fastify.register(cors, () => {
return ( return (
req: FastifyRequest, req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void, callback: (error: Error | null, options: FastifyCorsOptions) => void
) => { ) => {
// TODO: set prefix on dashboard routes // TODO: set prefix on dashboard routes
const corsPaths = [ const corsPaths = [
@@ -97,7 +98,7 @@ const startServer = async () => {
]; ];
const isPrivatePath = corsPaths.some((path) => const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path), req.url.startsWith(path)
); );
if (isPrivatePath) { if (isPrivatePath) {
@@ -118,6 +119,7 @@ const startServer = async () => {
return callback(null, { return callback(null, {
origin: '*', origin: '*',
maxAge: 86_400 * 7, // cache preflight for 7 days
}); });
}; };
}); });
@@ -149,19 +151,19 @@ const startServer = async () => {
try { try {
const sessionId = decodeSessionToken(req.cookies?.session); const sessionId = decodeSessionToken(req.cookies?.session);
const session = await runWithAlsSession(sessionId, () => const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session), validateSessionToken(req.cookies.session)
); );
req.session = session; req.session = session;
} catch (e) { } catch {
req.session = EMPTY_SESSION; req.session = EMPTY_SESSION;
} }
} else if (process.env.DEMO_USER_ID) { } else if (process.env.DEMO_USER_ID) {
try { try {
const session = await runWithAlsSession('1', () => const session = await runWithAlsSession('1', () =>
validateSessionToken(null), validateSessionToken(null)
); );
req.session = session; req.session = session;
} catch (e) { } catch {
req.session = EMPTY_SESSION; req.session = EMPTY_SESSION;
} }
} else { } else {
@@ -173,7 +175,7 @@ const startServer = async () => {
prefix: '/trpc', prefix: '/trpc',
trpcOptions: { trpcOptions: {
router: appRouter, router: appRouter,
createContext: createContext, createContext,
onError(ctx) { onError(ctx) {
if ( if (
ctx.error.code === 'UNAUTHORIZED' && ctx.error.code === 'UNAUTHORIZED' &&
@@ -194,8 +196,10 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' }); instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' }); instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' }); instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' }); instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' }); instance.register(aiRouter, { prefix: '/ai' });
instance.register(logsRouter, { prefix: '/logs' });
}); });
// Public API // Public API
@@ -217,39 +221,50 @@ const startServer = async () => {
reply.send({ reply.send({
status: 'ok', status: 'ok',
message: 'Successfully running OpenPanel.dev API', message: 'Successfully running OpenPanel.dev API',
}), })
); );
}); });
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
fastify.setErrorHandler((error, request, reply) => { fastify.setErrorHandler((error, request, reply) => {
if (error instanceof HttpError) { if (error.statusCode === 429) {
request.log.error(`${error.message}`, error); return reply.status(429).send({
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({
status: 429, status: 429,
error: 'Too Many Requests', error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.', 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') { if (process.env.NODE_ENV === 'production') {
@@ -274,7 +289,7 @@ const startServer = async () => {
} catch (error) { } catch (error) {
logger.warn('Failed to set redis notify-keyspace-events', error); logger.warn('Failed to set redis notify-keyspace-events', error);
logger.warn( 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.'); logger.warn('Otherwise some functions may not work as expected.');
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import type { FastifyPluginCallback } from 'fastify';
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'GET',
url: '/callback',
handler: gscGoogleCallback,
});
};
export default router;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { handler } from '@/controllers/logs.controller';
import type { FastifyInstance } from 'fastify';
export default async function (fastify: FastifyInstance) {
fastify.post('/', handler);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook'; import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
@@ -13,7 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({ fastify.route({
method: 'POST', method: 'POST',
url: '/', url: '/',
handler: handler, handler,
}); });
fastify.route({ fastify.route({
@@ -26,6 +25,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
type: 'object', type: 'object',
properties: { properties: {
deviceId: { type: 'string' }, deviceId: { type: 'string' },
sessionId: { type: 'string' },
message: { type: 'string', optional: true }, message: { type: 'string', optional: true },
}, },
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

181
apps/api/src/utils/ids.ts Normal file
View File

@@ -0,0 +1,181 @@
import crypto from 'node:crypto';
import { generateDeviceId } from '@openpanel/common/server';
import { getSafeJson } from '@openpanel/json';
import type {
EventsQueuePayloadCreateSessionEnd,
EventsQueuePayloadIncomingEvent,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { pick } from 'ramda';
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 getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
});
}
interface DeviceIdResult {
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
}
async function getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
}: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
}): Promise<DeviceIdResult> {
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<EventsQueuePayloadCreateSessionEnd>(
(res?.[0]?.[1] as string) ?? ''
);
if (data) {
return {
deviceId: currentDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
if (res?.[1]?.[1]) {
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[1]?.[1] as string) ?? ''
);
if (data) {
return {
deviceId: previousDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
} 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, '');
}

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,61 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title> <title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"> <meta
<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."> name="title"
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics"> content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
<meta name="author" content="OpenPanel"> >
<meta name="robots" content="index, follow"> <meta
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/"> name="description"
<link rel="icon" type="image/x-icon" href="/favicon.ico"> 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 --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/"> <meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"> <meta
<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."> property="og:title"
<meta property="og:image" content="/ogimage.png"> content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
<meta property="og:image:width" content="1200"> >
<meta property="og:image:height" content="630"> <meta
<meta property="og:site_name" content="Just Fucking Use OpenPanel"> 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 --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/"> <meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"> <meta
<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."> name="twitter:title"
<meta name="twitter:image" content="/ogimage.png"> 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 --> <!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a"> <meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -44,7 +65,9 @@
body { body {
background: #0a0a0a; background: #0a0a0a;
color: #e5e5e5; color: #e5e5e5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
font-size: 18px; font-size: 18px;
line-height: 1.75; line-height: 1.75;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
@@ -100,7 +123,8 @@
color: #60a5fa; color: #60a5fa;
} }
ul, ol { ul,
ol {
margin-left: 1.5rem; margin-left: 1.5rem;
margin-bottom: 1.25em; margin-bottom: 1.25em;
} }
@@ -123,7 +147,8 @@
margin: 2rem 0; margin: 2rem 0;
} }
th, td { th,
td {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #374151; border-bottom: 1px solid #374151;
@@ -264,242 +289,479 @@
color: #9ca3af; color: #9ca3af;
max-width: 100%; max-width: 100%;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="hero"> <div class="hero">
<h1>Just Fucking Use OpenPanel</h1> <h1>Just Fucking Use OpenPanel</h1>
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p> <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> </div>
<figure class="screenshot"> <script>
<div class="screenshot-inner"> 'use strict';
<div class="window-controls"> window.op =
<div class="window-dot red"></div> window.op ||
<div class="window-dot yellow"></div> (() => {
<div class="window-dot green"></div> var n = [];
</div> return new Proxy(
<div class="screenshot-image-wrapper"> function () {
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800"> arguments.length && n.push([].slice.call(arguments));
</div> },
</div> {
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption> get(t, r) {
</figure> returnr === 'q'
? n
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2> : function () {
n.push([r].concat([].slice.call(arguments)));
<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> has(t, r) {
<ul> returnr === 'q';
<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', { window.op('init', {
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0', clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
trackScreenViews: true, trackScreenViews: true,
trackOutgoingLinks: true, trackOutgoingLinks: true,
trackAttributes: true, trackAttributes: true,
}); });
</script> </script>
<script src="https://openpanel.dev/op1.js" defer async></script> <script src="https://openpanel.dev/op1.js" defer async></script>
</body> </body>
</html> </html>

View File

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

View File

@@ -1,7 +1,7 @@
--- ---
date: 2025-07-18 date: 2025-07-18
title: "13 Best Mixpanel Alternatives & Competitors in 2026" title: "13 Best Product Analytics Tools in 2026 (Ranked & Compared)"
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." description: "Compare the best product analytics tools in 2026. Side-by-side pricing, features, and privacy comparison of 13 platforms — including open source, self-hosted, and free options for every team size."
updated: 2026-02-16 updated: 2026-02-16
tag: Comparison tag: Comparison
team: OpenPanel Team team: OpenPanel Team

View File

@@ -3,18 +3,13 @@
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Amplitude Alternatives 2026 - Open Source, Free & Paid", "title": "Best Amplitude Alternatives 2026 - Open Source, Free & Paid",
"description": "Compare the best Amplitude alternatives in 2026: OpenPanel, PostHog, Mixpanel, Heap, and Plausible. Open source, privacy-first, and affordable options for every team size. See which fits you best.", "description": "Compare the best Amplitude alternatives in 2026: OpenPanel, PostHog, Heap, and Plausible. Open source, privacy-first, and affordable options for every team size. See which fits you best.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
"heading": "Best Amplitude Alternatives", "heading": "Best Amplitude Alternatives",
"subheading": "OpenPanel is an open-source, privacy-first alternative to Amplitude. Get powerful product analytics with web analytics built in, cookie-free tracking, and the freedom to self-host or use our cloud.", "subheading": "OpenPanel is an open-source, privacy-first alternative to Amplitude. Get powerful product analytics with web analytics built in, cookie-free tracking, and the freedom to self-host or use our cloud.",
"badges": [ "badges": ["Open-source", "Cookie-free", "EU-only hosting", "Self-hostable"]
"Open-source",
"Cookie-free",
"EU-only hosting",
"Self-hostable"
]
}, },
"competitor": { "competitor": {
"name": "Amplitude", "name": "Amplitude",
@@ -47,7 +42,7 @@
"Large enterprises with dedicated analytics teams", "Large enterprises with dedicated analytics teams",
"Organizations that need advanced experimentation and feature flags", "Organizations that need advanced experimentation and feature flags",
"Teams requiring sophisticated behavioral cohorts and predictive analytics", "Teams requiring sophisticated behavioral cohorts and predictive analytics",
"Companies wanting an all-in-one platform with session replay and guides" "Companies wanting an all-in-one platform with guides, surveys, and advanced experimentation"
] ]
}, },
"highlights": { "highlights": {
@@ -184,9 +179,9 @@
}, },
{ {
"name": "Session replay", "name": "Session replay",
"openpanel": false, "openpanel": true,
"competitor": true, "competitor": true,
"notes": "Included in Amplitude platform" "notes": "Both platforms include session replay"
}, },
{ {
"name": "Custom dashboards", "name": "Custom dashboards",
@@ -423,7 +418,7 @@
}, },
{ {
"title": "Simpler analytics needs", "title": "Simpler analytics needs",
"description": "If you don't need predictive ML models, feature flags, or session replay, OpenPanel gives you core analytics without the bloat.", "description": "If you don't need predictive ML models or feature flags, OpenPanel gives you core analytics — including session replay — without the enterprise bloat.",
"icon": "target" "icon": "target"
} }
] ]
@@ -484,7 +479,7 @@
}, },
{ {
"question": "What Amplitude features will I lose?", "question": "What Amplitude features will I lose?",
"answer": "OpenPanel doesn't have feature flags, session replay, predictive cohorts, or the Guides & Surveys product. If you rely heavily on these enterprise features, Amplitude may still be the better fit." "answer": "OpenPanel doesn't have feature flags, predictive cohorts, or the Guides & Surveys product. OpenPanel does include session replay. If you rely heavily on Amplitude's enterprise experimentation or ML-powered features, Amplitude may still be the better fit."
}, },
{ {
"question": "How does the SDK size affect my app?", "question": "How does the SDK size affect my app?",

View File

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

View File

@@ -274,9 +274,7 @@
"Android", "Android",
"Flutter" "Flutter"
], ],
"competitor": [ "competitor": ["JavaScript (web only)"],
"JavaScript (web only)"
],
"notes": null "notes": null
}, },
{ {

View File

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

View File

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

View File

@@ -2,8 +2,8 @@
"slug": "fullstory-alternative", "slug": "fullstory-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best FullStory Alternative 2026 - Open Source & Free", "title": "Best FullStory Alternatives 2026 — Cheaper & Privacy-First",
"description": "Looking for a FullStory alternative? OpenPanel offers product analytics with transparent pricing, self-hosting, and privacy-first tracking \u2014 no expensive session replay costs. Free to start.", "description": "FullStory pricing starts at $300/month. OpenPanel delivers product analytics — events, funnels, and retention — at $2.50/month or free to self-host. No enterprise contract required.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
@@ -353,7 +353,7 @@
}, },
{ {
"title": "Remove FullStory script", "title": "Remove FullStory script",
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to session replay and heatmaps." "description": "Once verified, remove the FullStory snippet. Note: You'll lose access to FullStory's advanced heatmaps, frustration signals, and pixel-perfect replay. OpenPanel includes basic session replay."
} }
], ],
"sdk_compatibility": { "sdk_compatibility": {

View File

@@ -2,8 +2,8 @@
"slug": "heap-alternative", "slug": "heap-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Heap Alternative 2026 - Open Source & Free", "title": "Best Heap Alternatives 2026 — After the Contentsquare Acquisition",
"description": "Looking for a Heap alternative? OpenPanel offers transparent pricing, lightweight analytics, and self-hosting without autocapture complexity. Open source and free to get started.", "description": "Heap was acquired by Contentsquare in 2023. If you're re-evaluating, OpenPanel is an open-source alternative with transparent pricing from $2.50/month, full self-hosting, and no sales call required.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
@@ -27,8 +27,8 @@
"overview": { "overview": {
"title": "Why consider OpenPanel over Heap?", "title": "Why consider OpenPanel over Heap?",
"paragraphs": [ "paragraphs": [
"Heap made its name with autocapture \u2014 the ability to automatically record every user interaction and analyze it retroactively. It's a compelling feature for teams that want to ask questions about user behavior without planning instrumentation in advance. But Heap's acquisition by Contentsquare, opaque enterprise pricing, and cloud-only architecture have many teams looking for alternatives.", "Heap was acquired by Contentsquare in September 2023. For many teams, that acquisition raised real questions: Will pricing change? Will the product roadmap shift to serve Contentsquare's enterprise customers? Will independent support continue? These concerns, combined with Heap's opaque pricing model and cloud-only architecture, have driven a wave of teams to evaluate alternatives.",
"OpenPanel takes a different approach with explicit event tracking, giving you precise control over what you measure and how. While you lose Heap's retroactive analysis capability, you gain transparency \u2014 both in your data collection and your costs. OpenPanel's pricing is publicly listed and event-based, starting at just $2.50 per month, compared to Heap's sales-required pricing that reportedly starts at $3,600 per year.", "OpenPanel takes a different approach with explicit event tracking, giving you precise control over what you measure and how. While you lose Heap's retroactive analysis capability, you gain transparency \u2014 both in your data collection and your costs. OpenPanel's pricing is publicly listed and event-based, starting at just $2.50 per month, compared to Heap's sales-required pricing that reportedly starts at $3,600 per year. And unlike Heap, OpenPanel is fully self-hostable and open source \u2014 no acquisition can change that.",
"For teams that value data sovereignty, OpenPanel offers full self-hosting via a simple Docker deployment \u2014 something Heap doesn't provide at all. Being open source under the MIT license means you can inspect every line of code, contribute improvements, and avoid the vendor lock-in risk that comes with Heap's proprietary, now-Contentsquare-owned platform.", "For teams that value data sovereignty, OpenPanel offers full self-hosting via a simple Docker deployment \u2014 something Heap doesn't provide at all. Being open source under the MIT license means you can inspect every line of code, contribute improvements, and avoid the vendor lock-in risk that comes with Heap's proprietary, now-Contentsquare-owned platform.",
"If you prefer intentional, controlled analytics over autocapture-everything, want transparent pricing without sales calls, and need the option to self-host \u2014 OpenPanel gives you solid product analytics with full ownership of your data." "If you prefer intentional, controlled analytics over autocapture-everything, want transparent pricing without sales calls, and need the option to self-host \u2014 OpenPanel gives you solid product analytics with full ownership of your data."
] ]
@@ -443,8 +443,8 @@
], ],
"articles": [ "articles": [
{ {
"title": "Find an alternative to Mixpanel", "title": "Best product analytics tools in 2026",
"url": "/articles/alternatives-to-mixpanel" "url": "/articles/mixpanel-alternatives"
}, },
{ {
"title": "9 best open source web analytics tools", "title": "9 best open source web analytics tools",

View File

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

View File

@@ -2,13 +2,13 @@
"slug": "mixpanel-alternative", "slug": "mixpanel-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Mixpanel Alternative 2026 - Open Source & Free", "title": "OpenPanel vs Mixpanel (2026): Full Feature & Pricing Comparison",
"description": "Looking for a Mixpanel alternative? OpenPanel offers powerful product analytics at a fraction of the cost \u2014 with EU-only hosting, self-hosting, and full data ownership. Try free today.", "description": "Side-by-side comparison of OpenPanel and Mixpanel: pricing, features, self-hosting, privacy, and migration guide. See which product analytics platform is right for your team.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
"heading": "Best Mixpanel Alternative", "heading": "OpenPanel vs Mixpanel",
"subheading": "OpenPanel is an open-source, privacy-first alternative to Mixpanel. Get powerful product analytics\u2014events, funnels, retention, and user profiles\u2014without event-based pricing that scales to thousands per month or sending your data to US servers.", "subheading": "A complete side-by-side comparison of OpenPanel and Mixpanel \u2014 pricing, features, self-hosting, privacy, and what it takes to switch. Make an informed decision before you migrate.",
"badges": [ "badges": [
"Open-source", "Open-source",
"EU-only hosting", "EU-only hosting",
@@ -45,7 +45,7 @@
], ],
"best_for_competitor": [ "best_for_competitor": [
"Enterprise teams needing advanced experimentation and feature flags", "Enterprise teams needing advanced experimentation and feature flags",
"Organizations requiring session replay across web and mobile", "Teams needing Metric Trees for organizational goal alignment",
"Companies with complex data warehouse integration needs", "Companies with complex data warehouse integration needs",
"Teams that need Metric Trees for organizational alignment" "Teams that need Metric Trees for organizational alignment"
] ]
@@ -184,9 +184,15 @@
}, },
{ {
"name": "Session replay", "name": "Session replay",
"openpanel": false, "openpanel": true,
"competitor": true, "competitor": true,
"notes": "Mixpanel supports web, iOS, and Android" "notes": "Mixpanel supports web, iOS, and Android. OpenPanel also offers session replay."
},
{
"name": "Group analytics",
"openpanel": true,
"competitor": true,
"notes": "Both support group/company-level analytics"
}, },
{ {
"name": "Revenue tracking", "name": "Revenue tracking",
@@ -441,7 +447,7 @@
"items": [ "items": [
{ {
"question": "Does OpenPanel have all the features I use in Mixpanel?", "question": "Does OpenPanel have all the features I use in Mixpanel?",
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, and A/B testing. If you rely heavily on Mixpanel's session replay, feature flags, or Metric Trees, those aren't available in OpenPanel yet." "answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, A/B testing, session replay, and group analytics. If you rely heavily on Mixpanel's feature flags or Metric Trees, those aren't available in OpenPanel."
}, },
{ {
"question": "Can I import my historical Mixpanel data?", "question": "Can I import my historical Mixpanel data?",

View File

@@ -139,9 +139,9 @@
"features": [ "features": [
{ {
"name": "Session replay", "name": "Session replay",
"openpanel": false, "openpanel": true,
"competitor": true, "competitor": true,
"notes": null "notes": "Mouseflow's session replay is more advanced with friction scoring and form analytics"
}, },
{ {
"name": "Click heatmaps", "name": "Click heatmaps",
@@ -280,9 +280,7 @@
"Android", "Android",
"Flutter" "Flutter"
], ],
"competitor": [ "competitor": ["JavaScript (web only)"],
"JavaScript (web only)"
],
"notes": null "notes": null
}, },
{ {

View File

@@ -2,8 +2,8 @@
"slug": "posthog-alternative", "slug": "posthog-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best PostHog Alternative 2026 - Open Source & Free", "title": "Best PostHog Alternatives in 2026 — Simpler, Free & Self-Hosted",
"description": "Looking for a PostHog alternative? OpenPanel offers simpler analytics with better privacy, a lighter SDK, and transparent pricing \u2014 no complex tiers. Open source and free to self-host.", "description": "Looking for a simpler PostHog alternative? OpenPanel is free, open-source, and self-hostable — 2.3 KB SDK, cookie-free tracking, and no complex feature flags or session replay you don't need.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
@@ -28,7 +28,7 @@
"title": "Why consider OpenPanel over PostHog?", "title": "Why consider OpenPanel over PostHog?",
"paragraphs": [ "paragraphs": [
"PostHog has built an impressive all-in-one platform with product analytics, feature flags, session replay, surveys, A/B testing, and more \u2014 over 10 products under one roof. It's a popular choice among developer-led teams who want everything in a single tool. But that breadth comes with trade-offs: a 52+ KB SDK, complex multi-product pricing, and a self-hosted setup that requires ClickHouse, Kafka, Redis, and PostgreSQL.", "PostHog has built an impressive all-in-one platform with product analytics, feature flags, session replay, surveys, A/B testing, and more \u2014 over 10 products under one roof. It's a popular choice among developer-led teams who want everything in a single tool. But that breadth comes with trade-offs: a 52+ KB SDK, complex multi-product pricing, and a self-hosted setup that requires ClickHouse, Kafka, Redis, and PostgreSQL.",
"OpenPanel takes a focused approach. Instead of trying to be everything, it delivers excellent analytics \u2014 events, funnels, retention, cohorts, user profiles, and web analytics \u2014 with a dramatically smaller footprint. The SDK is just 2.3 KB (over 20x lighter than PostHog), which directly translates to faster page loads and better Core Web Vitals for your users.", "OpenPanel takes a focused approach. Instead of trying to be everything, it delivers excellent analytics \u2014 events, funnels, retention, cohorts, user profiles, session replay, and web analytics \u2014 with a dramatically smaller footprint. The SDK is just 2.3 KB (over 20x lighter than PostHog), which directly translates to faster page loads and better Core Web Vitals for your users.",
"Cookie-free tracking is another key difference. PostHog uses cookies by default and requires configuration to go cookieless, while OpenPanel is cookie-free out of the box \u2014 no consent banners needed. Self-hosting is also far simpler: OpenPanel runs in a single Docker container compared to PostHog's multi-service architecture.", "Cookie-free tracking is another key difference. PostHog uses cookies by default and requires configuration to go cookieless, while OpenPanel is cookie-free out of the box \u2014 no consent banners needed. Self-hosting is also far simpler: OpenPanel runs in a single Docker container compared to PostHog's multi-service architecture.",
"If you need focused analytics without the feature bloat, want a lighter SDK that doesn't impact performance, and prefer simple event-based pricing over multi-product metering \u2014 OpenPanel gives you exactly what you need without the overhead." "If you need focused analytics without the feature bloat, want a lighter SDK that doesn't impact performance, and prefer simple event-based pricing over multi-product metering \u2014 OpenPanel gives you exactly what you need without the overhead."
] ]
@@ -38,13 +38,13 @@
"intro": "Both are open-source analytics platforms. PostHog is an all-in-one platform with many products. OpenPanel focuses on analytics with simplicity.", "intro": "Both are open-source analytics platforms. PostHog is an all-in-one platform with many products. OpenPanel focuses on analytics with simplicity.",
"one_liner": "PostHog is an all-in-one platform with 10+ products; OpenPanel focuses on analytics with a lighter footprint.", "one_liner": "PostHog is an all-in-one platform with 10+ products; OpenPanel focuses on analytics with a lighter footprint.",
"best_for_openpanel": [ "best_for_openpanel": [
"Teams wanting focused analytics without feature flags, session replay, or surveys", "Teams wanting focused analytics without feature flags or surveys",
"Privacy-conscious products needing cookie-free tracking by default", "Privacy-conscious products needing cookie-free tracking by default",
"Performance-conscious applications (2.3KB SDK vs 52KB+)", "Performance-conscious applications (2.3KB SDK vs 52KB+)",
"Teams preferring simple Docker deployment over multi-service architecture" "Teams preferring simple Docker deployment over multi-service architecture"
], ],
"best_for_competitor": [ "best_for_competitor": [
"Teams needing all-in-one platform (analytics, feature flags, session replay, surveys)", "Teams needing all-in-one platform (analytics, feature flags, surveys, A/B experiments)",
"Developers wanting SQL access (HogQL) for custom queries", "Developers wanting SQL access (HogQL) for custom queries",
"Y Combinator companies leveraging PostHog's ecosystem", "Y Combinator companies leveraging PostHog's ecosystem",
"Teams requiring extensive CDP capabilities with 60+ connectors" "Teams requiring extensive CDP capabilities with 60+ connectors"
@@ -61,7 +61,7 @@
"notes": "OpenPanel's SDK is over 20x smaller than PostHog's core library, resulting in faster page loads and better Core Web Vitals." "notes": "OpenPanel's SDK is over 20x smaller than PostHog's core library, resulting in faster page loads and better Core Web Vitals."
}, },
{ {
"label": "Cookie-Free by Default", "label": "Cookie-Free",
"openpanel": "Yes", "openpanel": "Yes",
"competitor": "Requires Configuration", "competitor": "Requires Configuration",
"notes": "OpenPanel is truly cookie-free out of the box. PostHog requires specific configuration for cookieless tracking with reduced functionality." "notes": "OpenPanel is truly cookie-free out of the box. PostHog requires specific configuration for cookieless tracking with reduced functionality."
@@ -176,9 +176,9 @@
}, },
{ {
"name": "Session Replay", "name": "Session Replay",
"openpanel": false, "openpanel": true,
"competitor": true, "competitor": true,
"notes": "PostHog includes session replay for web, Android (beta), iOS (alpha)" "notes": "Both platforms offer session replay."
}, },
{ {
"name": "Surveys", "name": "Surveys",
@@ -391,7 +391,7 @@
"items": [ "items": [
{ {
"title": "Teams Who Want Analytics Without Feature Bloat", "title": "Teams Who Want Analytics Without Feature Bloat",
"description": "If you need product analytics but don't use PostHog's feature flags, session replay, surveys, or experiments, OpenPanel gives you exactly what you need without the overhead.", "description": "If you need product analytics and session replay but don't need PostHog's feature flags, surveys, or experiments, OpenPanel gives you exactly what you need without the overhead.",
"icon": "target" "icon": "target"
}, },
{ {
@@ -430,7 +430,7 @@
}, },
{ {
"question": "What features will I lose switching from PostHog?", "question": "What features will I lose switching from PostHog?",
"answer": "PostHog includes feature flags, session replay, surveys, and A/B experiments in their platform. If you actively use these, you'd need separate tools. If you primarily use PostHog for analytics, OpenPanel provides everything you need with less complexity." "answer": "PostHog includes feature flags, surveys, and A/B experiments in their platform. If you actively use these, you'd need separate tools. OpenPanel now includes session replay, so you won't lose that. If you primarily use PostHog for analytics, OpenPanel provides everything you need with less complexity."
}, },
{ {
"question": "How does OpenPanel compare on privacy?", "question": "How does OpenPanel compare on privacy?",
@@ -442,7 +442,7 @@
}, },
{ {
"question": "Is PostHog more feature-rich than OpenPanel?", "question": "Is PostHog more feature-rich than OpenPanel?",
"answer": "PostHog offers more products (10+ including feature flags, session replay, surveys, A/B testing, data warehouse). However, this comes with added complexity. OpenPanel focuses on doing analytics exceptionally well with a simpler, more focused experience." "answer": "PostHog offers more products (10+ including feature flags, surveys, A/B testing, data warehouse). However, this comes with added complexity. OpenPanel now includes session replay alongside its core analytics, while staying focused on simplicity and performance."
}, },
{ {
"question": "How do SDK sizes compare?", "question": "How do SDK sizes compare?",

View File

@@ -2,13 +2,13 @@
"slug": "smartlook-alternative", "slug": "smartlook-alternative",
"page_type": "alternative", "page_type": "alternative",
"seo": { "seo": {
"title": "Best Smartlook Alternative 2026 - Open Source & Free", "title": "5 Best Smartlook Alternatives in 2026 (Free & Open Source)",
"description": "Looking for a Smartlook alternative? OpenPanel offers product analytics with self-hosting, transparent pricing, and mobile SDKs \u2014 without session replay costs. Open source and free to start.", "description": "Looking for a Smartlook alternative? OpenPanel is open source with product analytics, session replay, funnels, and retention. Self-hostable, cookie-free, and no consent banners required.",
"noindex": false "noindex": false
}, },
"hero": { "hero": {
"heading": "Best Smartlook Alternative", "heading": "Best Smartlook Alternative",
"subheading": "Need product analytics without requiring session replay? OpenPanel is an open-source alternative to Smartlook that focuses on event-based analytics, funnels, and retention\u2014with self-hosting and transparent pricing.", "subheading": "OpenPanel is an open-source alternative to Smartlook with event-based product analytics, session replay, funnels, and retention\u2014with self-hosting, transparent pricing, and no Cisco vendor lock-in.",
"badges": [ "badges": [
"Open-source", "Open-source",
"Self-hostable", "Self-hostable",
@@ -28,28 +28,27 @@
"title": "Why consider OpenPanel over Smartlook?", "title": "Why consider OpenPanel over Smartlook?",
"paragraphs": [ "paragraphs": [
"Smartlook combines product analytics with visual insights \u2014 session recordings, heatmaps, and event tracking in one platform. Since its acquisition by Cisco in 2023, it has positioned itself as an enterprise-ready analytics and observation tool. But enterprise ownership often means enterprise pricing, proprietary lock-in, and cloud-only infrastructure with no option for self-hosting.", "Smartlook combines product analytics with visual insights \u2014 session recordings, heatmaps, and event tracking in one platform. Since its acquisition by Cisco in 2023, it has positioned itself as an enterprise-ready analytics and observation tool. But enterprise ownership often means enterprise pricing, proprietary lock-in, and cloud-only infrastructure with no option for self-hosting.",
"OpenPanel focuses purely on product analytics without the session replay overhead, delivering event tracking, funnels, retention analysis, and cohort breakdowns with a cleaner, more focused experience. The result is a lighter tool that does analytics well rather than trying to be everything \u2014 and at a dramatically lower cost with transparent, event-based pricing starting at $2.50 per month.", "OpenPanel delivers event tracking, funnels, retention analysis, cohort breakdowns, and session replay in a focused, open-source package. The result is a tool that covers both product analytics and visual session review \u2014 at a dramatically lower cost with transparent, event-based pricing starting at $2.50 per month.",
"Being open source under the MIT license gives OpenPanel advantages that Smartlook's proprietary, Cisco-owned platform can't match. You can self-host on your own infrastructure for complete data sovereignty, audit the source code for security compliance, and avoid the vendor lock-in risk that comes with acquisition-prone platforms. Self-hosting also means unlimited data retention, compared to Smartlook's plan-based limits.", "Being open source under the MIT license gives OpenPanel advantages that Smartlook's proprietary, Cisco-owned platform can't match. You can self-host on your own infrastructure for complete data sovereignty, audit the source code for security compliance, and avoid the vendor lock-in risk that comes with acquisition-prone platforms. Self-hosting also means unlimited data retention, compared to Smartlook's plan-based limits.",
"If you need session replay specifically, Smartlook has the edge in that area. But for teams that want focused, cost-effective product analytics with open-source transparency and the freedom to self-host, OpenPanel delivers more value without the enterprise complexity." "If you need advanced heatmaps or Unity/game analytics, Smartlook has the edge. But for teams that want product analytics plus session replay with open-source transparency, self-hosting, and predictable pricing, OpenPanel delivers more value without the Cisco enterprise complexity."
] ]
}, },
"summary_comparison": { "summary_comparison": {
"title": "OpenPanel vs Smartlook: Which is right for you?", "title": "OpenPanel vs Smartlook: Which is right for you?",
"intro": "Both platforms offer product analytics, but Smartlook adds visual behavior tools (session replay, heatmaps) while OpenPanel focuses on event-based analytics with self-hosting.", "intro": "Both platforms offer product analytics and session replay. Smartlook adds heatmaps and frustration signals; OpenPanel adds self-hosting, open source, and simpler pricing.",
"one_liner": "OpenPanel is open source with self-hosting for product analytics; Smartlook combines analytics with session replay and heatmaps.", "one_liner": "OpenPanel is open source with self-hosting, product analytics, and session replay; Smartlook adds heatmaps and deeper visual behavior tools.",
"best_for_openpanel": [ "best_for_openpanel": [
"Teams needing self-hosting for data ownership and compliance", "Teams needing self-hosting for data ownership and compliance",
"Open source requirements for transparency", "Open source requirements for transparency and auditability",
"Focus on event-based product analytics without visual replay", "Product analytics plus session replay without Cisco vendor lock-in",
"Teams wanting unlimited data retention with self-hosting", "Teams wanting unlimited data retention with self-hosting",
"Server-side SDKs for backend tracking" "Server-side SDKs for backend tracking"
], ],
"best_for_competitor": [ "best_for_competitor": [
"Teams needing session recordings to watch user interactions", "UX designers requiring comprehensive heatmaps (click, scroll, movement)",
"UX designers requiring heatmaps (click, scroll, movement)",
"Mobile app crash reports with linked session recordings", "Mobile app crash reports with linked session recordings",
"Teams wanting combined analytics and replay in one tool", "Teams needing Unity game analytics",
"Unity game developers (Smartlook supports Unity)" "Teams requiring Cisco/AppDynamics ecosystem integration"
] ]
}, },
"highlights": { "highlights": {
@@ -68,8 +67,8 @@
}, },
{ {
"label": "Session replay", "label": "Session replay",
"openpanel": "Not available", "openpanel": "Yes",
"competitor": "Yes, full recordings" "competitor": "Yes, with heatmaps & friction detection"
}, },
{ {
"label": "Heatmaps", "label": "Heatmaps",
@@ -139,9 +138,9 @@
"features": [ "features": [
{ {
"name": "Session recordings", "name": "Session recordings",
"openpanel": false, "openpanel": true,
"competitor": true, "competitor": true,
"notes": null "notes": "Smartlook additionally links recordings to crash reports and heatmaps"
}, },
{ {
"name": "Click heatmaps", "name": "Click heatmaps",
@@ -311,13 +310,13 @@
}, },
"migration": { "migration": {
"title": "Migrating from Smartlook to OpenPanel", "title": "Migrating from Smartlook to OpenPanel",
"intro": "Moving from Smartlook to OpenPanel involves transitioning from combined session replay and analytics to event-based product analytics.", "intro": "Moving from Smartlook to OpenPanel means keeping session replay and product analytics while gaining self-hosting, open source, and simpler pricing.",
"difficulty": "moderate", "difficulty": "moderate",
"estimated_time": "2-4 hours", "estimated_time": "2-4 hours",
"steps": [ "steps": [
{ {
"title": "Understand feature differences", "title": "Understand feature differences",
"description": "OpenPanel focuses on event-based product analytics. If you rely on session recordings and heatmaps, consider using complementary tools like Microsoft Clarity." "description": "OpenPanel includes session replay and event-based product analytics. If you rely on heatmaps or Unity analytics, consider using complementary tools like Microsoft Clarity for heatmaps."
}, },
{ {
"title": "Create OpenPanel account or self-host", "title": "Create OpenPanel account or self-host",
@@ -382,11 +381,11 @@
"items": [ "items": [
{ {
"question": "Can OpenPanel replace Smartlook's session recordings?", "question": "Can OpenPanel replace Smartlook's session recordings?",
"answer": "No, OpenPanel does not provide session recordings or heatmaps. If you need visual behavior analytics, consider using Microsoft Clarity (free) or Hotjar alongside OpenPanel, or continue using Smartlook for recordings while using OpenPanel for deeper product analytics." "answer": "Yes for session replay — OpenPanel now includes session recording. However, if you need heatmaps (click, scroll, movement), frustration signals, or Unity game analytics, Smartlook still has the edge in those areas."
}, },
{ {
"question": "Which tool has better funnel analysis?", "question": "Which tool has better funnel analysis?",
"answer": "Both tools offer funnel analysis. Smartlook's advantage is the ability to watch session recordings of users who dropped off. OpenPanel offers more advanced funnel customization and cohort breakdowns." "answer": "Both tools offer funnel analysis. With OpenPanel you can also watch session recordings of users who dropped off, and OpenPanel offers more advanced funnel customization and cohort breakdowns."
}, },
{ {
"question": "Can I self-host Smartlook?", "question": "Can I self-host Smartlook?",

View File

@@ -0,0 +1,76 @@
---
title: Consent management
description: Queue all tracking until the user gives consent, then flush everything with a single call.
---
import { Callout } from 'fumadocs-ui/components/callout';
Some jurisdictions require explicit user consent before you can track events or record sessions. OpenPanel has built-in support for this: initialise with `disabled: true` and nothing is sent until you call `ready()`.
## How it works
When `disabled: true` is set, all calls to `track`, `identify`, `screenView`, and session replay chunks are held in an in-memory queue instead of being sent to the API. Once the user consents, call `ready()` and the entire queue is flushed immediately.
```ts
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
disabled: true, // nothing sent until ready() is called
});
// Later, when the user accepts your consent banner:
op.ready();
```
If the user declines, simply don't call `ready()`. The queue is discarded when the page unloads.
## With session replay
Session replay chunks are also queued while `disabled: true`. Once `ready()` is called, buffered replay chunks flush along with any queued events.
```ts
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
disabled: true,
trackScreenViews: true,
sessionReplay: { enabled: true },
});
// User accepts consent:
op.ready();
```
<Callout type="info">
The replay recorder starts as soon as the page loads (so no interactions are missed), but no data is sent until `ready()` is called.
</Callout>
## Waiting for a user profile
If you want to hold events until you know who the user is rather than waiting for explicit consent, use `waitForProfile` instead. Events are queued until `identify()` is called with a `profileId`.
```ts
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
waitForProfile: true,
});
// Events queue here...
op.track('page_view');
// Queue is flushed once a profileId is set:
op.identify({ profileId: 'user_123' });
```
If the user never authenticates, the queue is never flushed automatically — no events will be sent. To handle anonymous users or guest flows, call `ready()` explicitly when you know the user won't identify:
```ts
// User skipped login — flush queued events without a profileId
op.ready();
```
`ready()` always releases the queue regardless of whether `waitForProfile` or `disabled` is set.
## Related
- [Consent management guide](/guides/consent-management) — full walkthrough with a cookie banner example
- [Session replay](/docs/session-replay) — privacy controls for replay recordings
- [Identify users](/docs/get-started/identify-users) — link events to a user profile

View File

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

View File

@@ -33,7 +33,7 @@ This is the most common flow and most secure one. Your backend receives webhooks
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" /> <FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website"> <FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint. When you create the checkout, you should first call `op.getDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
```javascript ```javascript
fetch('https://domain.com/api/checkout', { fetch('https://domain.com/api/checkout', {
@@ -42,7 +42,7 @@ fetch('https://domain.com/api/checkout', {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now deviceId: op.getDeviceId(), // ✅ since deviceId is here we can link the payment now
// ... other checkout data // ... other checkout data
}), }),
}) })
@@ -360,5 +360,5 @@ op.clearRevenue(): void
### Fetch your current users device id ### Fetch your current users device id
```javascript ```javascript
op.fetchDeviceId(): Promise<string> op.getDeviceId(): string
``` ```

View File

@@ -54,7 +54,8 @@ import { OpenPanelComponent } from '@openpanel/astro';
##### Astro options ##### Astro options
- `profileId` - If you have a user id, you can pass it here to identify the user - `profileId` - If you have a user id, you can pass it here to identify the user
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) - `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter) - `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
- `globalProperties` - This is an object of properties that will be sent with every event. - `globalProperties` - This is an object of properties that will be sent with every event.

View File

@@ -68,6 +68,34 @@ app.listen(3000, () => {
- `trackRequest` - A function that returns `true` if the request should be tracked. - `trackRequest` - A function that returns `true` if the request should be tracked.
- `getProfileId` - A function that returns the profile ID of the user making the request. - `getProfileId` - A function that returns the profile ID of the user making the request.
## Working with Groups
Groups let you track analytics at the account or company level. Since Express is a backend SDK, you can upsert groups and assign users from your route handlers.
See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
```ts
app.post('/login', async (req, res) => {
const user = await loginUser(req.body);
// Identify the user
req.op.identify({ profileId: user.id, email: user.email });
// Create/update the group entity
req.op.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan },
});
// Assign the user to the group
req.op.setGroup(user.organizationId);
res.json({ ok: true });
});
```
## Typescript ## Typescript
If `req.op` is not typed you can extend the `Request` interface. If `req.op` is not typed you can extend the `Request` interface.

View File

@@ -116,9 +116,38 @@ op.decrement({
}); });
``` ```
### Working with Groups
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
**Create or update a group:**
```js title="index.js"
import { op } from './op.ts'
op.upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'enterprise' },
});
```
**Assign the current user to a group** (call after `identify`):
```js title="index.js"
import { op } from './op.ts'
op.setGroup('org_acme');
// or multiple groups:
op.setGroups(['org_acme', 'team_eng']);
```
Once set, all subsequent `track()` calls will automatically include the group IDs.
### Clearing User Data ### Clearing User Data
To clear the current user's data: To clear the current user's data (including groups):
```js title="index.js" ```js title="index.js"
import { op } from './op.ts' import { op } from './op.ts'

View File

@@ -62,7 +62,8 @@ export default function RootLayout({ children }) {
##### NextJS options ##### NextJS options
- `profileId` - If you have a user id, you can pass it here to identify the user - `profileId` - If you have a user id, you can pass it here to identify the user
- `cdnUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`) - `cdnUrl` (deprecated) - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `scriptUrl` - The url to the OpenPanel SDK (default: `https://openpanel.dev/op1.js`)
- `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter) - `filter` - This is a function that will be called before tracking an event. If it returns false the event will not be tracked. [Read more](#filter)
- `globalProperties` - This is an object of properties that will be sent with every event. - `globalProperties` - This is an object of properties that will be sent with every event.
@@ -226,9 +227,32 @@ useOpenPanel().decrement({
}); });
``` ```
### Working with Groups
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
**Create or update a group:**
```tsx title="app/login/page.tsx"
useOpenPanel().upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'enterprise' },
});
```
**Assign the current user to a group** (call after `identify`):
```tsx title="app/login/page.tsx"
useOpenPanel().setGroup('org_acme');
```
Once set, all subsequent `track()` calls will automatically include the group IDs.
### Clearing User Data ### Clearing User Data
To clear the current user's data: To clear the current user's data (including groups):
```js title="index.js" ```js title="index.js"
useOpenPanel().clear() useOpenPanel().clear()
@@ -286,12 +310,12 @@ import { createRouteHandler } from '@openpanel/nextjs/server';
export const { GET, POST } = createRouteHandler(); export const { GET, POST } = createRouteHandler();
``` ```
Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server. Remember to change the `apiUrl` and `scriptUrl` in the `OpenPanelComponent` to your own server.
```tsx ```tsx
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op" // [!code highlight] apiUrl="/api/op" // [!code highlight]
cdnUrl="/api/op/op1.js" // [!code highlight] scriptUrl="/api/op/op1.js" // [!code highlight]
clientId="your-client-id" clientId="your-client-id"
trackScreenViews={true} trackScreenViews={true}
/> />

View File

@@ -32,7 +32,9 @@ npx expo install expo-application expo-constants
On native we use a clientSecret to authenticate the app. On native we use a clientSecret to authenticate the app.
```typescript ```typescript
const op = new Openpanel({ import { OpenPanel } from '@openpanel/react-native';
const op = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}', clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}', clientSecret: '{YOUR_CLIENT_SECRET}',
}); });
@@ -118,3 +120,35 @@ op.track('my_event', { foo: 'bar' });
</Tabs> </Tabs>
For more information on how to use the SDK, check out the [Javascript SDK](/docs/sdks/javascript#usage). For more information on how to use the SDK, check out the [Javascript SDK](/docs/sdks/javascript#usage).
## Offline support
The SDK can buffer events when the device is offline and flush them once connectivity is restored. Events are stamped with a `__timestamp` at the time they are fired so they are recorded with the correct time even if they are delivered later.
Two optional peer dependencies enable this feature:
```npm
npm install @react-native-async-storage/async-storage @react-native-community/netinfo
```
Pass them to the constructor:
```typescript
import { OpenPanel } from '@openpanel/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
const op = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
// Persist the event queue across app restarts
storage: AsyncStorage,
// Automatically flush the queue when the device comes back online
networkInfo: NetInfo,
});
```
Both options are independent — you can use either one or both:
- **`storage`** — persists the queue to disk so events survive app restarts while offline.
- **`networkInfo`** — flushes the queue automatically when connectivity is restored. Without this, the queue is flushed the next time the app becomes active.

View File

@@ -174,9 +174,37 @@ function MyComponent() {
} }
``` ```
### Working with Groups
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
```tsx
import { op } from '@/openpanel';
function LoginComponent() {
const handleLogin = async (user: User) => {
// 1. Identify the user
op.identify({ profileId: user.id, email: user.email });
// 2. Create/update the group entity (only when data changes)
op.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan },
});
// 3. Link the user to their group — tags all future events
op.setGroup(user.organizationId);
};
return <button onClick={() => handleLogin(user)}>Login</button>;
}
```
### Clearing User Data ### Clearing User Data
To clear the current user's data: To clear the current user's data (including groups):
```tsx ```tsx
import { op } from '@/openpanel'; import { op } from '@/openpanel';

View File

@@ -0,0 +1,185 @@
---
title: Session Replay
description: Record and replay user sessions to understand exactly what users did. Loaded asynchronously so it never bloats your analytics bundle.
---
import { Callout } from 'fumadocs-ui/components/callout';
Session replay captures a structured recording of what users do in your app or website. You can replay any session to see which elements were clicked, how forms were filled, and where users ran into friction—without guessing.
<Callout type="info">
Session replay is **not enabled by default**. You explicitly opt in per-project. When disabled, the replay script is never downloaded, keeping your analytics bundle lean.
</Callout>
## How it works
OpenPanel session replay is built on [rrweb](https://www.rrweb.io/), an open-source library for recording and replaying web sessions. It captures DOM mutations, mouse movements, scroll positions, and interactions as structured data—not video.
The replay module is loaded **asynchronously** as a separate script (`op1-replay.js`). This means:
- Your main tracking script (`op1.js`) stays lightweight even when replay is disabled
- The replay module is only downloaded for sessions that are actually recorded
- No impact on page load performance when replay is turned off
## Limits & retention
- **Unlimited replays** — no cap on the number of sessions recorded
- **30-day retention** — replays are stored and accessible for 30 days
## Setup
### Script tag
Add `sessionReplay` to your `init` call. The replay script loads automatically from the same CDN as the main script.
```html title="index.html"
<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: 'YOUR_CLIENT_ID',
trackScreenViews: true,
sessionReplay: {
enabled: true,
},
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
sessionReplay: {
enabled: true,
},
});
```
With the npm package, the replay module is a dynamic import code-split by your bundler. It is never included in your main bundle when session replay is disabled.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `enabled` | `boolean` | `false` | Enable session replay recording |
| `maskAllInputs` | `boolean` | `true` | Mask all input field values |
| `maskAllText` | `boolean` | `true` | Mask all text content in the recording |
| `unmaskTextSelector` | `string` | — | CSS selector for elements whose text should NOT be masked when `maskAllText` is true |
| `blockSelector` | `string` | `[data-openpanel-replay-block]` | CSS selector for elements to replace with a placeholder |
| `blockClass` | `string` | — | Class name that blocks elements from being recorded |
| `ignoreSelector` | `string` | — | CSS selector for elements excluded from interaction tracking |
| `flushIntervalMs` | `number` | `10000` | How often (ms) recorded events are sent to the server |
| `maxEventsPerChunk` | `number` | `200` | Maximum number of events per payload chunk |
| `maxPayloadBytes` | `number` | `1048576` | Maximum payload size in bytes (1 MB) |
| `scriptUrl` | `string` | — | Custom URL for the replay script (script-tag builds only) |
## Privacy controls
Session replay captures user interactions. All text and inputs are masked by default — sensitive content is replaced with `***` before it ever leaves the browser.
### Text masking (default on)
All text content is masked by default (`maskAllText: true`). This means visible page text, labels, and content are replaced with `***` in replays, in addition to input fields.
This is the safest default for GDPR compliance since replays cannot incidentally capture names, emails, or other personal data visible on the page.
### Selectively unmasking text
If your pages display non-sensitive content you want visible in replays, use `unmaskTextSelector` to opt specific elements out of masking:
```ts
sessionReplay: {
enabled: true,
unmaskTextSelector: '[data-openpanel-unmask]',
}
```
```html
<h1 data-openpanel-unmask>Product Analytics</h1>
<p data-openpanel-unmask>Welcome to the dashboard</p>
<!-- This stays masked: -->
<p>John Doe · john@example.com</p>
```
You can also use any CSS selector to target elements by class, tag, or attribute:
```ts
sessionReplay: {
enabled: true,
unmaskTextSelector: '.replay-safe, nav, footer',
}
```
### Disabling full text masking
If you want to disable full text masking and return to selector-based masking, set `maskAllText: false`. In this mode only elements with `data-openpanel-replay-mask` are masked:
```ts
sessionReplay: {
enabled: true,
maskAllText: false,
}
```
```html
<p data-openpanel-replay-mask>This will be masked</p>
<p>This will be visible in replays</p>
```
<Callout type="warn">
Only disable `maskAllText` if you are confident your pages do not display personal data, or if you are masking all sensitive elements individually. You are responsible for ensuring your use of session replay complies with applicable privacy law.
</Callout>
### Blocking elements
Elements matched by `blockSelector` or `blockClass` are replaced with a same-size grey placeholder in the replay. The element and all its children are never recorded.
```html
<div data-openpanel-replay-block>
This section won't appear in replays at all
</div>
```
Or with a custom selector:
```ts
sessionReplay: {
enabled: true,
blockSelector: '.payment-form, .user-avatar',
blockClass: 'no-replay',
}
```
### Ignoring interactions
Use `ignoreSelector` to exclude specific elements from interaction tracking. The element remains visible in the replay but clicks and input events on it are not recorded.
```ts
sessionReplay: {
enabled: true,
ignoreSelector: '.debug-panel',
}
```
## Self-hosting
If you self-host OpenPanel, the replay script is served from your instance automatically. You can also override the script URL if you host it separately:
```ts
sessionReplay: {
enabled: true,
scriptUrl: 'https://your-cdn.example.com/op1-replay.js',
}
```
## Related
- [Session tracking](/features/session-tracking) — understand sessions without full replay
- [Session replay feature overview](/features/session-replay) — what you get with session replay
- [Web SDK](/docs/sdks/web) — full web SDK reference
- [Script tag](/docs/sdks/script) — using OpenPanel via a script tag

View File

@@ -53,14 +53,32 @@ GET /export/events
| `end` | string | End date for the event range (ISO format) | `2024-04-18` | | `end` | string | End date for the event range (ISO format) | `2024-04-18` |
| `page` | number | Page number for pagination (default: 1) | `2` | | `page` | number | Page number for pagination (default: 1) | `2` |
| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` | | `limit` | number | Number of events per page (default: 50, max: 1000) | `100` |
| `includes` | string or string[] | Additional fields to include in the response | `profile` or `["profile","meta"]` | | `includes` | string or string[] | Additional fields to include in the response. Pass multiple as comma-separated (`profile,meta`) or repeated params (`includes=profile&includes=meta`). | `profile` or `profile,meta` |
#### Include Options #### Include Options
The `includes` parameter allows you to fetch additional related data: The `includes` parameter allows you to fetch additional related data. When using query parameters, you can pass multiple values in either of these ways:
- `profile`: Include user profile information - **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response)
- `meta`: Include event metadata and configuration - **Repeated parameter**: `?includes=profile&includes=meta` (same result; useful when building URLs programmatically)
Supported values (any of these can be combined; names match the response keys):
**Related data** (adds nested objects or extra lookups):
- `profile` — User profile for the event (id, email, firstName, lastName, etc.)
- `meta` — Event metadata from project config (name, description, conversion flag)
**Event fields** (optional columns; these are in addition to the default fields):
- `properties` — Custom event properties
- `region`, `longitude`, `latitude` — Extra geo (default already has `city`, `country`)
- `osVersion`, `browserVersion`, `device`, `brand`, `model` — Extra device (default already has `os`, `browser`)
- `origin`, `referrer`, `referrerName`, `referrerType` — Referrer/navigation
- `revenue` — Revenue amount
- `importedAt`, `sdkName`, `sdkVersion` — Import/SDK info
The response always includes: `id`, `name`, `deviceId`, `profileId`, `sessionId`, `projectId`, `createdAt`, `path`, `duration`, `city`, `country`, `os`, `browser`. Use `includes` to add any of the values above.
#### Example Request #### Example Request
@@ -129,12 +147,15 @@ Retrieve aggregated chart data for analytics and visualization. This endpoint pr
GET /export/charts GET /export/charts
``` ```
**Note:** The endpoint accepts either `series` or `events` for the event configuration; `series` is the preferred parameter name. Both use the same structure.
#### Query Parameters #### Query Parameters
| Parameter | Type | Description | Example | | Parameter | Type | Description | Example |
|-----------|------|-------------|---------| |-----------|------|-------------|---------|
| `projectId` | string | The ID of the project to fetch chart data from | `abc123` | | `projectId` | string | The ID of the project to fetch chart data from | `abc123` |
| `events` | object[] | Array of event configurations to analyze | `[{"name":"screen_view","filters":[]}]` | | `series` | object[] | Array of event/series configurations to analyze (preferred over `events`) | `[{"name":"screen_view","filters":[]}]` |
| `events` | object[] | Array of event configurations (deprecated in favor of `series`) | `[{"name":"screen_view","filters":[]}]` |
| `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` | | `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` |
| `interval` | string | Time interval for data points | `day` | | `interval` | string | Time interval for data points | `day` |
| `range` | string | Predefined date range | `7d` | | `range` | string | Predefined date range | `7d` |
@@ -144,7 +165,7 @@ GET /export/charts
#### Event Configuration #### Event Configuration
Each event in the `events` array supports the following properties: Each item in the `series` or `events` array supports the following properties:
| Property | Type | Description | Required | Default | | Property | Type | Description | Required | Default |
|----------|------|-------------|----------|---------| |----------|------|-------------|----------|---------|
@@ -228,11 +249,13 @@ Common breakdown dimensions include:
#### Example Request #### Example Request
```bash ```bash
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&events=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \ curl 'https://api.openpanel.dev/export/charts?projectId=abc123&series=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \
-H 'openpanel-client-id: YOUR_CLIENT_ID' \ -H 'openpanel-client-id: YOUR_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
``` ```
You can use `events` instead of `series` in the query for backward compatibility; both accept the same structure.
#### Example Advanced Request #### Example Advanced Request
```bash ```bash
@@ -241,7 +264,7 @@ curl 'https://api.openpanel.dev/export/charts' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \ -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \
-G \ -G \
--data-urlencode 'projectId=abc123' \ --data-urlencode 'projectId=abc123' \
--data-urlencode 'events=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \ --data-urlencode 'series=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \
--data-urlencode 'breakdowns=[{"name":"country"}]' \ --data-urlencode 'breakdowns=[{"name":"country"}]' \
--data-urlencode 'interval=day' \ --data-urlencode 'interval=day' \
--data-urlencode 'range=30d' --data-urlencode 'range=30d'

View File

@@ -106,6 +106,81 @@ curl -X POST https://api.openpanel.dev/track \
}' }'
``` ```
### Creating or updating a group
```bash
curl -X POST https://api.openpanel.dev/track \
-H "Content-Type: application/json" \
-H "openpanel-client-id: YOUR_CLIENT_ID" \
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
-d '{
"type": "group",
"payload": {
"id": "org_acme",
"type": "company",
"name": "Acme Inc",
"properties": {
"plan": "enterprise",
"seats": 25
}
}
}'
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | `string` | Yes | Unique identifier for the group |
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
| `name` | `string` | Yes | Display name |
| `properties` | `object` | No | Custom metadata |
### Assigning a user to a group
Links a profile to one or more groups. This updates the profile record but does not auto-attach groups to future events — you still need to pass `groups` explicitly on each track call.
```bash
curl -X POST https://api.openpanel.dev/track \
-H "Content-Type: application/json" \
-H "openpanel-client-id: YOUR_CLIENT_ID" \
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
-d '{
"type": "assign_group",
"payload": {
"profileId": "user_123",
"groupIds": ["org_acme"]
}
}'
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `profileId` | `string` | No | Profile to assign. Falls back to the device ID if omitted |
| `groupIds` | `string[]` | Yes | Group IDs to link to the profile |
### Tracking events with groups
Groups are never auto-populated on events — even if the profile has been assigned to a group via `assign_group`. Pass `groups` on every track event where you want group data.
```bash
curl -X POST https://api.openpanel.dev/track \
-H "Content-Type: application/json" \
-H "openpanel-client-id: YOUR_CLIENT_ID" \
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
-d '{
"type": "track",
"payload": {
"name": "report_exported",
"profileId": "user_123",
"groups": ["org_acme"],
"properties": {
"format": "pdf"
}
}
}'
```
Unlike the SDK, where `setGroup()` stores group IDs on the instance and attaches them to every subsequent `track()` call, the API has no such state. You must pass `groups` on each event.
### Error Handling ### Error Handling
The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error. The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error.
Example error response: Example error response:

View File

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

View File

@@ -0,0 +1,138 @@
---
title: "How to set up notifications and integrations"
description: "Get notified in Slack, Discord, or via webhook when users complete events or funnels. Learn how to connect integrations and configure notification rules in OpenPanel."
difficulty: beginner
timeToComplete: 10
date: 2026-02-27
updated: 2026-02-27
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Create an integration"
anchor: "create-integration"
- name: "Create a notification rule"
anchor: "create-rule"
- name: "Event rules"
anchor: "event-rules"
- name: "Funnel rules"
anchor: "funnel-rules"
- name: "View notifications"
anchor: "view-notifications"
---
## How it works
There are two separate concepts to understand before you start:
- **Integrations** are connections to external services like Slack, Discord, or a custom webhook. They live at the workspace/organization level and can be reused across all your projects.
- **Notification rules** are the conditions that trigger a notification. Rules live inside individual projects and reference one or more integrations. A rule does nothing until it has an integration attached—and an integration does nothing until a rule uses it.
- **Notifications** are the messages that are sent when a rule is triggered. A notification can be sent as a json object or a template with variables.
## Step 1: Create an integration [#create-integration]
Go to your workspace settings and open the **Integrations** section. Click **Add integration** and choose the service you want to connect.
OpenPanel currently supports:
- **Slack** — authenticate via OAuth and pick a channel
- **Discord** — paste a Discord webhook URL for a channel
- **Webhook** — send an HTTP POST to any URL you control
Fill in the required details and save. The integration is now available to all projects in your workspace.
<Figure
src="/screenshots/integrations-create.webp"
caption="Create a new integration for Slack, Discord, or a custom webhook."
/>
<Callout>Soon we have integrations for S3 and GCS to export your events to your own storage.</Callout>
## Step 2: Go to your project's notification rules [#create-rule]
Integrations alone don't do anything. To start receiving alerts, open the project you want to monitor, click **Notifications** in the left sidebar, and switch to the **Rules** tab.
Click **Add Rule** to open the rule editor on the right side of the screen.
Give your rule a name, then choose a **Type**. There are two types:
| Type | When it triggers |
|------|-----------------|
| **Event** | Immediately when a matching event is received |
| **Funnel** | After a session ends and all funnel steps have been completed in order |
## Event rules [#event-rules]
Event rules fire in real time. The moment OpenPanel receives an event that matches your filters, the notification is sent.
<Figure
src="/screenshots/notifications-event-rule.webp"
caption="An event rule called 'Onboarding user' that fires when a screen_view event occurs with path filters matching the onboarding flow."
/>
In the rule editor:
1. Set **Type** to **Events**
2. Add one or more events from the **Events** list. You can filter each event by its properties (for example, only trigger when `path` starts with `/onboarding`)
3. Write a **Template** for the notification message. Use `{{property_name}}` to insert event properties dynamically—for example, `New user with their first event from {{country}}`.
4. Under **Integrations**, select which integration(s) should receive the notification
Click **Update** to save the rule.
<Callout>Templates will not be used if you have Javascript transformer in your integration.</Callout>
## Funnel rules [#funnel-rules]
Funnel rules let you track multi-step flows and notify you only when a user completes every step in the correct sequence—for example, `session_start` → `subscription_checkout` → `subscription_created`.
<Figure
src="/screenshots/notifications-funnel-rule.webp"
caption="A funnel rule called 'Subscribe funnel' that notifies when a session completes all three steps in order."
/>
In the rule editor:
1. Set **Type** to **Funnel**
2. Add each event in the funnel, in the order they must occur. You can optionally add property filters to each step
3. Write a **Template** for the notification message
4. Select your **Integration(s)**
Click **Update** to save.
<Callout type="warning">**Important:** Funnel rule notifications are sent after the session ends, not immediately when the last step fires. OpenPanel waits until the session is complete before evaluating the funnel sequence.</Callout>
<Callout>Templates will not be used if you have Javascript transformer in your integration.</Callout>
## View notifications [#view-notifications]
Switch to the **Notifications** tab (the default view) to see every notification that has been triggered for your project. Each row shows the notification title alongside the country, OS, browser, and profile of the user who triggered it.
<Figure
src="/screenshots/notifications-list.webp"
caption="The Notifications tab shows a live feed of every triggered notification, with user context like country, OS, and browser."
/>
You can filter the list by creation date or search by title to find specific events.
## Frequently asked questions
<Faqs>
<FaqItem question="Can I use the same integration across multiple projects?">
Yes. Integrations are created at the workspace level, so any project in your organization can reference them in its notification rules.
</FaqItem>
<FaqItem question="Why haven't I received any funnel notifications?">
Funnel rules trigger after the session ends, not when the last event fires. If the user's session is still active, the notification is queued until the session closes. Make sure the full funnel sequence was completed within a single session.
</FaqItem>
<FaqItem question="Can I filter event rules to only fire for specific users or properties?">
Yes. For each event in the rule, click the filter icon to add property conditions—for example, only trigger when `plan` equals `enterprise` or `country` equals `US`.
</FaqItem>
<FaqItem question="What integrations are supported?">
Currently Slack, Discord, and custom webhooks. More integrations are coming soon.
</FaqItem>
<FaqItem question="Can I have multiple integrations on one rule?">
Yes. The integrations selector on each rule allows you to pick multiple destinations. A single triggered rule will send a notification to all selected integrations simultaneously.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,202 @@
---
title: "Understand the overview"
description: "The overview is the main page of every OpenPanel project. It gives you a real-time picture of how your site or app is performing right now and over any time range you choose. This page explains every section and every number so you know exactly what you're looking at."
date: 2026-02-27
---
## Top stats
The row of metric cards at the top of the page is the fastest way to understand the health of your project. Each card shows the value for the selected time range and a comparison to the previous period of the same length.
### Unique Visitors
The number of distinct profile IDs recorded in the selected period. How accurate this is depends on whether you use [identify](/docs/get-started/identify-users):
- **Without identify**: OpenPanel generates an anonymous profile ID that rotates every 24 hours. A visitor returning on 10 different days will be counted as 10 unique visitors, because each day produces a new ID.
- **With identify**: The profile ID is tied to the user's real identity. The same person visiting on 10 different days is counted as 1 unique visitor across the entire period.
If cross-day deduplication matters to your analysis, set up [user identification](/docs/get-started/identify-users).
### Sessions
The total number of sessions in the selected period. A session begins when someone arrives on your site and ends after 30 minutes of inactivity or when they close the tab. One visitor can have many sessions across a day.
### Pageviews
The total number of page views (`screen_view` events) recorded across all sessions. Every time a visitor loads a page—including navigating between pages in a single session—it counts as one pageview.
### Pages per Session
The average number of pages viewed within a single session, calculated as `total pageviews / total sessions`. A higher number means visitors are exploring more of your site before leaving.
### Bounce Rate
The percentage of sessions where a visitor viewed only a single page and left. Calculated as `single-page sessions / total sessions × 100`. Lower is generally better—it means more visitors are engaging beyond the first page.
> A session is counted as a bounce if the visitor triggered exactly one `screen_view` event before the session ended. Sessions where visitors read one article deeply and leave still count as bounces.
### Session Duration
The average length of a session in seconds, calculated only from sessions where the visitor did something after the first page load (duration > 0). Sessions where a visitor immediately left are excluded from the average to avoid skewing the number.
### Revenue
The total monetary value tracked via `revenue` events in the selected period, displayed in your account currency. Revenue is only shown if you are tracking revenue events. See the [revenue tracking docs](/features/revenue-tracking) for setup instructions.
---
## The time-series chart
Directly below the stat cards is a line chart that shows how the selected metric changes over time. Click any stat card to switch the chart to that metric.
The chart uses the **interval** you select (hour, day, week, or month) to group data points. A faint dashed line shows the equivalent period from the previous comparison window, so you can spot trends at a glance.
When any metric other than Revenue is active, the chart also overlays revenue as green bars on a secondary Y-axis—this lets you correlate traffic patterns with revenue without switching cards.
The trailing edge of the line (the current, incomplete interval) is shown as a dashed segment to remind you that the period is still accumulating data.
---
## Insights
A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed.
Each card shows:
- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
- **Absolute change**: The raw increase or decrease in sessions compared to the previous period
- **Percentage change**: How much that property grew or declined relative to its own previous value
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
---
## Sources
The Sources widget shows where your visitors came from. Switch between tabs to see different dimensions:
| Tab | What it shows |
|-----|---------------|
| **Refs** | Grouped referrer names (e.g., "Google", "Twitter", "Hacker News") |
| **Urls** | Raw referrer URLs |
| **Types** | Referrer categories: `search`, `social`, `email`, `unknown` |
| **Source** | `utm_source` query parameter values |
| **Medium** | `utm_medium` query parameter values |
| **Campaign** | `utm_campaign` query parameter values |
| **Term** | `utm_term` query parameter values |
| **Content** | `utm_content` query parameter values |
Referrer names and types are resolved automatically from the raw referrer URL using a built-in lookup table. Direct traffic (no referrer) appears as `(not set)`.
Each row shows sessions and pageviews. Clicking a row filters the entire overview page to only show data from that source.
---
## Pages
The Pages widget shows which URLs your visitors are landing on, exiting from, and spending time on.
| Tab | What it shows |
|-----|---------------|
| **Top pages** | Pages ranked by unique sessions. Each row is a `origin + path` combination. |
| **Entry pages** | The first page of each session—the page where visitors arrived. |
| **Exit pages** | The last page of each session—the page where visitors left. |
High exit rates on a page are not always bad—they can reflect a page that successfully answers a question. High bounce on an entry page is more diagnostic. Compare entry and exit distributions to understand the shape of your user journeys.
Clicking a page row filters the whole overview to sessions that included that page.
---
## Devices
The Devices widget breaks down your audience by hardware and software. Switch between tabs:
| Tab | What it shows |
|-----|---------------|
| **Device** | Device type: Desktop, Mobile, Tablet |
| **Brand** | Hardware brand (Apple, Samsung, etc.) |
| **Model** | Specific device model |
| **Browser** | Browser name (Chrome, Safari, Firefox, etc.) |
| **Browser ver.** | Browser version number |
| **OS** | Operating system (macOS, Windows, iOS, Android, etc.) |
| **OS ver.** | Operating system version |
Each row shows sessions and pageviews. Use this widget to prioritize which browsers and operating systems to test and optimize for.
---
## Events
The Events widget shows the most frequent custom events fired in the selected period, ranked by count. System events (`session_start`, `session_end`, `screen_view`) are excluded—only the events you instrument yourself appear here.
Click any event to filter the overview to sessions where that event was fired.
---
## Geo
The Geo widget shows the geographic distribution of your visitors. Switch between tabs:
| Tab | What it shows |
|-----|---------------|
| **Country** | Visitor country, derived from IP geolocation |
| **Region** | State or province |
| **City** | City level |
Below the table, a world map plots the same data as a heatmap—darker areas represent more sessions. This gives you a quick visual of where your audience is concentrated.
Clicking a country, region, or city filters the whole overview to that location.
---
## Activity heatmap
The activity heatmap at the bottom of the page shows when your visitors are most active, broken down by day of the week (Monday through Sunday) and hour of the day (00:0023:00). Each cell shows the **average** of the selected metric at that day-and-hour combination, averaged across all weeks in the selected period.
Darker cells indicate higher average values. Hover any cell to see the exact average.
You can switch the metric being visualized using the tabs above the heatmap:
- **Unique Visitors**
- **Sessions**
- **Pageviews**
- **Bounce Rate**
- **Pages / Session**
- **Session Duration**
Use the heatmap to identify peak traffic windows, plan campaigns, and schedule maintenance during quiet periods.
---
## User Journey
The User Journey (Sankey) diagram at the very bottom visualizes how visitors flow through your site within a session. It answers the question: after landing on page A, where do visitors go next?
**How it works:**
1. OpenPanel identifies the top 3 most common entry pages in the selected period.
2. From each entry page, it finds the top 3 most frequent next pages (step 2), then the top 3 from those (step 3), and so on up to the configured number of steps (default 5, adjustable to a maximum of 10).
3. Paths that represent less than 0.25% of total sessions are filtered out to reduce visual noise.
4. Consecutive duplicate pages within a session are collapsed into one step (e.g., if someone refreshed a page, it only counts once in the journey).
Each node shows the page URL. The width of the connecting flows is proportional to the number of sessions that followed that path.
Use the User Journey to find drop-off points, discover unexpected popular paths, and understand whether visitors are reaching your key conversion pages.
---
## Filters and time controls
Every widget on the overview page responds to the same set of global filters and time controls at the top of the page.
**Range**: choose a preset (Today, Last 7 days, Last 30 days, etc.) or a custom date range.
**Interval**: controls how data is grouped in the time-series chart (hour, day, week, month).
**Event filter**: narrow the entire overview to sessions that include a specific event—useful for analyzing the behavior of users who completed a particular action.
**Dimension filters**: clicking any row in any widget (a country, a source, a page) applies that value as a filter. Active filters are shown as chips below the time controls. Remove a filter by clicking the × on its chip.
**Live counter**: a green badge in the top-right corner shows the number of active visitors (visitors who fired an event in the last 5 minutes). Click it for a 30-minute session histogram.

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