62 Commits

Author SHA1 Message Date
be64494aa9 feat: add Gitea CI workflows, production compose, and update all deps
Some checks failed
Build and Push API / build-api (push) Failing after 39m34s
Build and Push Dashboard / build-dashboard (push) Has been cancelled
Build and Push Worker / build-worker (push) Has been cancelled
- Add .gitea/workflows for building and pushing api, dashboard, and worker images to git.zias.be registry
- Add docker-compose.prod.yml using pre-built registry images (no build-from-source on server)
- Update docker-compose.yml infra images to latest (postgres 18.3, redis 8.6.2, clickhouse 26.3.2.3)
- Update self-hosting/docker-compose.template.yml image versions to match
- Bump Node.js to 22.22.2 in all three Dockerfiles
- Update pnpm to 10.33.0 and upgrade all safe npm dependencies
- Add buffer-equal-constant-time patch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:18:55 +02:00
eefbeac7f8 feat: add logs UI — tRPC router, page, and sidebar nav
- logRouter with list (infinite query, cursor-based) and severityCounts procedures
- /logs route page: severity filter chips with counts, search input, expandable log rows
- Log detail expansion shows attributes, resource, trace context, device/location
- Sidebar "Logs" link with ScrollText icon between Events and Sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:08:39 +02:00
0672857974 feat: add OpenTelemetry device log capture pipeline
- ClickHouse `logs` table (migration 13) with OTel columns, bloom filter indices
- Zod validation schema for log payloads (severity, body, attributes, trace context)
- Redis-backed LogBuffer with micro-batching into ClickHouse
- POST /logs API endpoint with client auth, geo + UA enrichment
- BullMQ logs queue + worker job
- cron flushLogs every 10s wired into existing cron system
- SDK captureLog(severity, body, properties) with client-side batching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:04:04 +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
375 changed files with 37775 additions and 23959 deletions

View File

@@ -0,0 +1,55 @@
name: Build and Push API
on:
push:
branches: [main]
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: [main]
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: [main]
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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -10,14 +10,14 @@
"dependencies": {
"@openpanel/common": "workspace:*",
"@openpanel/db": "workspace:*",
"chalk": "^5.3.0",
"chalk": "^5.6.2",
"fuzzy": "^0.1.3",
"inquirer": "^9.3.5",
"inquirer": "^9.3.8",
"inquirer-autocomplete-prompt": "^3.0.1",
"jiti": "^2.4.2"
"jiti": "^2.6.1"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
"@types/inquirer": "^9.0.9",
"@types/inquirer-autocomplete-prompt": "^3.0.3",
"@types/node": "catalog:",
"typescript": "catalog:"

View File

@@ -1,4 +1,4 @@
ARG NODE_VERSION=22.20.0
ARG NODE_VERSION=22.22.2
FROM node:${NODE_VERSION}-slim AS base

View File

@@ -12,11 +12,11 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/openai": "^1.3.12",
"@fastify/compress": "^8.1.0",
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/openai": "^1.3.24",
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.2.0",
"@node-rs/argon2": "^2.0.2",
@@ -30,39 +30,39 @@
"@openpanel/logger": "workspace:*",
"@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*",
"groupmq": "catalog:",
"@openpanel/redis": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@openpanel/validation": "workspace:*",
"@trpc/server": "^11.6.0",
"ai": "^4.2.10",
"@trpc/server": "^11.16.0",
"ai": "^4.3.19",
"fast-json-stable-hash": "^1.0.3",
"fastify": "^5.6.1",
"fastify": "^5.8.4",
"fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1",
"sharp": "^0.33.5",
"groupmq": "catalog:",
"jsonwebtoken": "^9.0.3",
"ramda": "^0.32.0",
"sharp": "^0.34.5",
"source-map-support": "^0.5.21",
"sqlstring": "^2.3.3",
"superjson": "^1.13.3",
"svix": "^1.24.0",
"url-metadata": "^5.4.1",
"svix": "^1.89.0",
"url-metadata": "^5.4.3",
"uuid": "^9.0.1",
"zod": "catalog:"
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
"@faker-js/faker": "^9.9.0",
"@openpanel/tsconfig": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9",
"@types/ramda": "^0.30.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/ramda": "^0.31.1",
"@types/source-map-support": "^0.5.10",
"@types/sqlstring": "^2.3.2",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.14",
"js-yaml": "^4.1.0",
"tsdown": "0.14.2",
"@types/uuid": "^11.0.0",
"@types/ws": "^8.18.1",
"js-yaml": "^4.1.1",
"tsdown": "0.21.7",
"typescript": "catalog:"
}
}

View File

@@ -63,6 +63,7 @@ async function main() {
imported_at: null,
sdk_name: 'test-script',
sdk_version: '1.0.0',
groups: [],
});
}

View File

@@ -1,4 +1,4 @@
import { cacheable, cacheableLru } from '@openpanel/redis';
import { cacheable } from '@openpanel/redis';
import bots from './bots';
// 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 includesBots = compiledBots.filter((bot) => 'includes' in bot);
export const isBot = cacheableLru(
export const isBot = cacheable(
'is-bot',
(ua: string) => {
// Check simple string patterns first (fast)
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
return null;
},
{
maxSize: 1000,
ttl: 60 * 5,
},
60 * 5
);

View File

@@ -61,8 +61,6 @@ export async function postEvent(
},
uaInfo,
geo,
currentDeviceId: '',
previousDeviceId: '',
deviceId,
sessionId: sessionId ?? '',
},

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

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 { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isShuttingDown } from '@/utils/graceful-shutdown';
// For docker compose healthcheck
export async function healthcheck(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
try {
const redisRes = await getRedisCache().ping();
@@ -21,6 +21,7 @@ export async function healthcheck(
ch: chRes && chRes.length > 0,
});
} catch (error) {
request.log.warn('healthcheck failed', { error });
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
@@ -41,18 +42,22 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
// Perform lightweight dependency checks for readiness
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 isReady = redisRes && dbRes && chRes;
const isReady = redisRes;
if (!isReady) {
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
const res = {
redis: redisRes === 'PONG',
db: !!dbRes,
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,23 +1,10 @@
import type { FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket';
import {
eventBuffer,
getProfileById,
transformMinimalEvent,
} from '@openpanel/db';
import { eventBuffer } from '@openpanel/db';
import { setSuperJson } from '@openpanel/json';
import {
psubscribeToPublishedEvent,
subscribeToPublishedEvent,
} from '@openpanel/redis';
import { subscribeToPublishedEvent } from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc';
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
}
import type { FastifyRequest } from 'fastify';
export function wsVisitors(
socket: WebSocket,
@@ -25,32 +12,32 @@ export function wsVisitors(
Params: {
projectId: string;
};
}>,
}>
) {
const { params } = req;
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
if (event?.projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
const sendCount = () => {
eventBuffer
.getActiveVisitorCount(params.projectId)
.then((count) => {
socket.send(String(count));
})
.catch(() => {
socket.send('0');
});
}
});
};
const punsubscribe = psubscribeToPublishedEvent(
'__keyevent@0__:expired',
(key) => {
const [projectId] = getLiveEventInfo(key);
if (projectId && projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
socket.send(String(count));
});
const unsubscribe = subscribeToPublishedEvent(
'events',
'batch',
({ projectId }) => {
if (projectId === params.projectId) {
sendCount();
}
},
}
);
socket.on('close', () => {
unsubscribe();
punsubscribe();
});
}
@@ -62,18 +49,10 @@ export async function wsProjectEvents(
};
Querystring: {
token?: string;
type?: 'saved' | 'received';
};
}>,
}>
) {
const { params, query } = req;
const type = query.type || 'saved';
if (!['saved', 'received'].includes(type)) {
socket.send('Invalid type');
socket.close();
return;
}
const { params } = req;
const userId = req.session?.userId;
if (!userId) {
@@ -87,24 +66,20 @@ export async function wsProjectEvents(
projectId: params.projectId,
});
if (!access) {
socket.send('No access');
socket.close();
return;
}
const unsubscribe = subscribeToPublishedEvent(
'events',
type,
async (event) => {
if (event.projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId);
socket.send(
superjson.stringify(
access
? {
...event,
profile,
}
: transformMinimalEvent(event),
),
);
'batch',
({ projectId, count }) => {
if (projectId === params.projectId) {
socket.send(setSuperJson({ count }));
}
},
}
);
socket.on('close', () => unsubscribe());
@@ -116,7 +91,7 @@ export async function wsProjectNotifications(
Params: {
projectId: string;
};
}>,
}>
) {
const { params } = req;
const userId = req.session?.userId;
@@ -143,9 +118,9 @@ export async function wsProjectNotifications(
'created',
(notification) => {
if (notification.projectId === params.projectId) {
socket.send(superjson.stringify(notification));
socket.send(setSuperJson(notification));
}
},
}
);
socket.on('close', () => unsubscribe());
@@ -157,7 +132,7 @@ export async function wsOrganizationEvents(
Params: {
organizationId: string;
};
}>,
}>
) {
const { params } = req;
const userId = req.session?.userId;
@@ -184,7 +159,7 @@ export async function wsOrganizationEvents(
'subscription_updated',
(message) => {
socket.send(setSuperJson(message));
},
}
);
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 { HttpError } from '@/utils/errors';
import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server';
import {
@@ -10,6 +9,7 @@ import {
} from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
// Validation schemas
const zCreateProject = z.object({
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
// Projects CRUD
export async function listProjects(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const projects = await db.project.findMany({
where: {
@@ -74,7 +74,7 @@ export async function listProjects(
export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const project = await db.project.findFirst({
where: {
@@ -92,7 +92,7 @@ export async function getProject(
export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsed = zCreateProject.safeParse(request.body);
@@ -139,12 +139,9 @@ export async function createProject(
},
});
// Clear cache
await Promise.all([
getProjectByIdCached.clear(project.id),
project.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
]);
reply.send({
@@ -165,7 +162,7 @@ export async function updateProject(
Params: { id: string };
Body: z.infer<typeof zUpdateProject>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsed = zUpdateProject.safeParse(request.body);
@@ -223,12 +220,9 @@ export async function updateProject(
data: updateData,
});
// Clear cache
await Promise.all([
getProjectByIdCached.clear(project.id),
existing.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
]);
reply.send({ data: project });
@@ -236,7 +230,7 @@ export async function updateProject(
export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const project = await db.project.findFirst({
where: {
@@ -266,7 +260,7 @@ export async function deleteProject(
// Clients CRUD
export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const where: any = {
organizationId: request.client!.organizationId,
@@ -300,7 +294,7 @@ export async function listClients(
export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const client = await db.client.findFirst({
where: {
@@ -318,7 +312,7 @@ export async function getClient(
export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsed = zCreateClient.safeParse(request.body);
@@ -374,7 +368,7 @@ export async function updateClient(
Params: { id: string };
Body: z.infer<typeof zUpdateClient>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsed = zUpdateClient.safeParse(request.body);
@@ -417,7 +411,7 @@ export async function updateClient(
export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const client = await db.client.findFirst({
where: {
@@ -444,7 +438,7 @@ export async function deleteClient(
// References CRUD
export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const where: any = {};
@@ -488,7 +482,7 @@ export async function listReferences(
export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const reference = await db.reference.findUnique({
where: {
@@ -516,7 +510,7 @@ export async function getReference(
export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsed = zCreateReference.safeParse(request.body);
@@ -559,7 +553,7 @@ export async function updateReference(
Params: { id: string };
Body: z.infer<typeof zUpdateReference>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsed = zUpdateReference.safeParse(request.body);
@@ -616,7 +610,7 @@ export async function updateReference(
export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
reply: FastifyReply
) {
const reference = await db.reference.findUnique({
where: {

View File

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

View File

@@ -3,14 +3,20 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import {
getProfileById,
getSalts,
groupBuffer,
replayBuffer,
upsertProfile,
} from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import {
type EventsQueuePayloadIncomingEvent,
getEventsGroupQueueShard,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import {
type IAssignGroupPayload,
type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload,
type IIncrementPayload,
type IReplayPayload,
@@ -112,6 +118,7 @@ interface TrackContext {
identity?: IIdentifyPayload;
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
geo: GeoLocation;
}
@@ -141,19 +148,21 @@ async function buildContext(
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)
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
const { deviceId, sessionId } = await getDeviceId({
const deviceIdResult = await getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId:
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined,
overrideDeviceId,
});
return {
@@ -166,8 +175,9 @@ async function buildContext(
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
deviceId,
sessionId,
deviceId: deviceIdResult.deviceId,
sessionId: deviceIdResult.sessionId,
session: deviceIdResult.session,
geo,
};
}
@@ -176,13 +186,14 @@ async function handleTrack(
payload: ITrackPayload,
context: TrackContext
): Promise<void> {
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
context;
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}`
: undefined
: deviceId;
const jobId = [
slug(payload.name),
@@ -203,13 +214,14 @@ async function handleTrack(
}
promises.push(
getEventsGroupQueueShard(groupId).add({
getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value,
data: {
projectId,
headers,
event: {
...payload,
groups: payload.groups ?? [],
timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast,
},
@@ -217,8 +229,7 @@ async function handleTrack(
geo,
deviceId,
sessionId,
currentDeviceId: '', // TODO: Remove
previousDeviceId: '', // TODO: Remove
session,
},
groupId,
jobId,
@@ -326,6 +337,36 @@ async function handleReplay(
await replayBuffer.add(row);
}
async function handleGroup(
payload: IGroupPayload,
context: TrackContext
): Promise<void> {
const { id, type, name, properties = {} } = payload;
await groupBuffer.add({
id,
projectId: context.projectId,
type,
name,
properties,
});
}
async function handleAssignGroup(
payload: IAssignGroupPayload,
context: TrackContext
): Promise<void> {
const profileId = payload.profileId ?? context.deviceId;
if (!profileId) {
return;
}
await upsertProfile({
id: String(profileId),
projectId: context.projectId,
isExternal: !!payload.profileId,
groups: payload.groupIds,
});
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
@@ -374,6 +415,12 @@ export async function handler(
case 'replay':
await handleReplay(validatedBody.payload, context);
break;
case 'group':
await handleGroup(validatedBody.payload, context);
break;
case 'assign_group':
await handleAssignGroup(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,

View File

@@ -1,20 +1,19 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isBot } from '@/bots';
export async function isBotHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const bot = req.headers['user-agent']
? isBot(req.headers['user-agent'])
? await isBot(req.headers['user-agent'])
: null;
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,3 +1,4 @@
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
process.env.TZ = 'UTC';
import compress from '@fastify/compress';
@@ -35,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
@@ -42,6 +44,7 @@ import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
import logsRouter from './routes/logs.router';
import trackRouter from './routes/track.router';
import webhookRouter from './routes/webhook.router';
import { HttpError } from './utils/errors';
@@ -151,7 +154,7 @@ const startServer = async () => {
validateSessionToken(req.cookies.session)
);
req.session = session;
} catch (e) {
} catch {
req.session = EMPTY_SESSION;
}
} else if (process.env.DEMO_USER_ID) {
@@ -160,7 +163,7 @@ const startServer = async () => {
validateSessionToken(null)
);
req.session = session;
} catch (e) {
} catch {
req.session = EMPTY_SESSION;
}
} else {
@@ -193,6 +196,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});
@@ -206,6 +210,7 @@ const startServer = async () => {
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
instance.register(logsRouter, { prefix: '/logs' });
instance.register(manageRouter, { prefix: '/manage' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
@@ -220,35 +225,46 @@ const startServer = async () => {
);
});
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
fastify.setErrorHandler((error, request, reply) => {
if (error instanceof HttpError) {
request.log.error(`${error.message}`, error);
if (process.env.NODE_ENV === 'production' && error.status === 500) {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} else {
reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
} else if (error.statusCode === 429) {
reply.status(429).send({
if (error.statusCode === 429) {
return reply.status(429).send({
status: 429,
error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.',
});
} else if (error.statusCode === 400) {
reply.status(400).send({
status: 400,
error,
message: 'The request was invalid.',
});
} else {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
}
if (error instanceof HttpError) {
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('internal server error', { error });
}
if (process.env.NODE_ENV === 'production' && error.status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('request error', { error });
}
const status = error?.statusCode ?? 500;
if (process.env.NODE_ENV === 'production' && status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(status).send({
status,
error,
message: error.message,
});
});
if (process.env.NODE_ENV === 'production') {

View File

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

View File

@@ -0,0 +1,17 @@
import type { FastifyPluginCallback } from 'fastify';
import { handler } from '@/controllers/logs.controller';
import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
const logsRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preValidation', duplicateHook);
fastify.addHook('preHandler', clientHook);
fastify.route({
method: 'POST',
url: '/',
handler,
});
};
export default logsRouter;

View File

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

View File

@@ -1,7 +1,12 @@
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,
@@ -37,14 +42,20 @@ export async function getDeviceId({
ua,
});
return await getDeviceIdFromSession({
return await getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
});
}
async function getDeviceIdFromSession({
interface DeviceIdResult {
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
}
async function getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
@@ -52,7 +63,7 @@ async function getDeviceIdFromSession({
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
}) {
}): Promise<DeviceIdResult> {
try {
const multi = getRedisCache().multi();
multi.hget(
@@ -65,21 +76,33 @@ async function getDeviceIdFromSession({
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>(
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[0]?.[1] as string) ?? ''
);
if (data) {
const sessionId = data.payload.sessionId;
return { deviceId: currentDeviceId, sessionId };
return {
deviceId: currentDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
if (res?.[1]?.[1]) {
const data = getSafeJson<{ payload: { sessionId: string } }>(
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[1]?.[1] as string) ?? ''
);
if (data) {
const sessionId = data.payload.sessionId;
return { deviceId: previousDeviceId, sessionId };
return {
deviceId: previousDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
} catch (error) {

View File

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

View File

@@ -3,7 +3,7 @@
"page_type": "alternative",
"seo": {
"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
},
"hero": {
@@ -47,7 +47,7 @@
"Large enterprises with dedicated analytics teams",
"Organizations that need advanced experimentation and feature flags",
"Teams requiring sophisticated behavioral cohorts and predictive analytics",
"Companies wanting an all-in-one platform with session replay and guides"
"Companies wanting an all-in-one platform with guides, surveys, and advanced experimentation"
]
},
"highlights": {
@@ -184,9 +184,9 @@
},
{
"name": "Session replay",
"openpanel": false,
"openpanel": true,
"competitor": true,
"notes": "Included in Amplitude platform"
"notes": "Both platforms include session replay"
},
{
"name": "Custom dashboards",
@@ -423,7 +423,7 @@
},
{
"title": "Simpler analytics needs",
"description": "If you don't need predictive ML models, feature flags, or session replay, OpenPanel gives you core analytics without the bloat.",
"description": "If you don't need predictive ML models or feature flags, OpenPanel gives you core analytics — including session replay — without the enterprise bloat.",
"icon": "target"
}
]
@@ -484,7 +484,7 @@
},
{
"question": "What Amplitude features will I lose?",
"answer": "OpenPanel doesn't have feature flags, session replay, predictive cohorts, or the Guides & Surveys product. If you rely heavily on these enterprise features, Amplitude may still be the better fit."
"answer": "OpenPanel doesn't have feature flags, predictive cohorts, or the Guides & Surveys product. OpenPanel does include session replay. If you rely heavily on Amplitude's enterprise experimentation or ML-powered features, Amplitude may still be the better fit."
},
{
"question": "How does the SDK size affect my app?",

View File

@@ -2,8 +2,8 @@
"slug": "fullstory-alternative",
"page_type": "alternative",
"seo": {
"title": "Best FullStory Alternative 2026 - Open Source & Free",
"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.",
"title": "Best FullStory Alternatives 2026 — Cheaper & Privacy-First",
"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
},
"hero": {
@@ -353,7 +353,7 @@
},
{
"title": "Remove FullStory script",
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to session replay and heatmaps."
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to FullStory's advanced heatmaps, frustration signals, and pixel-perfect replay. OpenPanel includes basic session replay."
}
],
"sdk_compatibility": {

View File

@@ -2,8 +2,8 @@
"slug": "heap-alternative",
"page_type": "alternative",
"seo": {
"title": "Best Heap Alternative 2026 - Open Source & Free",
"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.",
"title": "Best Heap Alternatives 2026 — After the Contentsquare Acquisition",
"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
},
"hero": {
@@ -27,8 +27,8 @@
"overview": {
"title": "Why consider OpenPanel over Heap?",
"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.",
"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.",
"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. 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.",
"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": [
{
"title": "Find an alternative to Mixpanel",
"url": "/articles/alternatives-to-mixpanel"
"title": "Best product analytics tools in 2026",
"url": "/articles/mixpanel-alternatives"
},
{
"title": "9 best open source web analytics tools",

View File

@@ -2,13 +2,13 @@
"slug": "mixpanel-alternative",
"page_type": "alternative",
"seo": {
"title": "Best Mixpanel Alternative 2026 - Open Source & Free",
"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.",
"title": "OpenPanel vs Mixpanel (2026): Full Feature & Pricing Comparison",
"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
},
"hero": {
"heading": "Best Mixpanel Alternative",
"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.",
"heading": "OpenPanel vs Mixpanel",
"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": [
"Open-source",
"EU-only hosting",
@@ -45,7 +45,7 @@
],
"best_for_competitor": [
"Enterprise teams needing advanced experimentation and feature flags",
"Organizations requiring session replay across web and mobile",
"Teams needing Metric Trees for organizational goal alignment",
"Companies with complex data warehouse integration needs",
"Teams that need Metric Trees for organizational alignment"
]
@@ -184,9 +184,15 @@
},
{
"name": "Session replay",
"openpanel": false,
"openpanel": true,
"competitor": true,
"notes": "Mixpanel supports web, iOS, and Android"
"notes": "Mixpanel supports web, iOS, and Android. OpenPanel also offers session replay."
},
{
"name": "Group analytics",
"openpanel": true,
"competitor": true,
"notes": "Both support group/company-level analytics"
},
{
"name": "Revenue tracking",
@@ -441,7 +447,7 @@
"items": [
{
"question": "Does OpenPanel have all the features I use in Mixpanel?",
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, and A/B testing. If you rely heavily on Mixpanel's session replay, feature flags, or Metric Trees, those aren't available in OpenPanel yet."
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, A/B testing, session replay, and group analytics. If you rely heavily on Mixpanel's feature flags or Metric Trees, those aren't available in OpenPanel."
},
{
"question": "Can I import my historical Mixpanel data?",

View File

@@ -139,9 +139,9 @@
"features": [
{
"name": "Session replay",
"openpanel": false,
"openpanel": true,
"competitor": true,
"notes": null
"notes": "Mouseflow's session replay is more advanced with friction scoring and form analytics"
},
{
"name": "Click heatmaps",

View File

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

View File

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

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,3 @@
{
"pages": ["sdks", "how-it-works", "..."]
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
}

View File

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

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
To clear the current user's data:
To clear the current user's data (including groups):
```js title="index.js"
import { op } from './op.ts'

View File

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

View File

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

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
To clear the current user's data:
To clear the current user's data (including groups):
```tsx
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` |
| `page` | number | Page number for pagination (default: 1) | `2` |
| `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
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
- `meta`: Include event metadata and configuration
- **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response)
- **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
@@ -129,12 +147,15 @@ Retrieve aggregated chart data for analytics and visualization. This endpoint pr
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
| Parameter | Type | Description | Example |
|-----------|------|-------------|---------|
| `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"}]` |
| `interval` | string | Time interval for data points | `day` |
| `range` | string | Predefined date range | `7d` |
@@ -144,7 +165,7 @@ GET /export/charts
#### 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 |
|----------|------|-------------|----------|---------|
@@ -228,11 +249,13 @@ Common breakdown dimensions include:
#### Example Request
```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-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
```bash
@@ -241,7 +264,7 @@ curl 'https://api.openpanel.dev/export/charts' \
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \
-G \
--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 'interval=day' \
--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
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:

View File

@@ -0,0 +1,7 @@
{
"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.

View File

@@ -0,0 +1,208 @@
---
title: Groups
description: Track analytics at the account, company, or team level — not just individual users.
---
import { Callout } from 'fumadocs-ui/components/callout';
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
This is especially useful for B2B SaaS products where a single paying account has many users.
## How Groups work
There are two separate concepts:
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
## Creating or updating a group
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
```typescript
op.upsertGroup({
id: 'org_acme', // Your group's unique ID
type: 'company', // Group type (company, workspace, team, etc.)
name: 'Acme Inc', // Display name
properties: {
plan: 'enterprise',
seats: 25,
industry: 'logistics',
},
});
```
### Group payload
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | `string` | Yes | Unique identifier for the group |
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
| `name` | `string` | Yes | Human-readable display name |
| `properties` | `object` | No | Custom metadata about the group |
## Managing groups in the dashboard
The easiest way to create, edit, and delete groups is directly in the OpenPanel dashboard. Navigate to your project and open the **Groups** section — from there you can manage group names, types, and properties without touching any code.
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
<Callout>
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
</Callout>
## Assigning a user to a group
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
```typescript
// After login
op.identify({ profileId: 'user_123' });
// Link the user to their organization
op.setGroup('org_acme');
```
For users that belong to multiple groups:
```typescript
op.setGroups(['org_acme', 'team_engineering']);
```
<Callout>
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
</Callout>
## Full login flow example
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
```typescript
// 1. Identify the user
op.identify({
profileId: 'user_123',
firstName: 'Jane',
email: 'jane@acme.com',
});
// 2. Assign the user to the group — the group doesn't need to exist yet
op.setGroup('org_acme');
// 3. All subsequent events are now tagged with the group
op.track('dashboard_viewed'); // → includes groups: ['org_acme']
op.track('report_exported'); // → includes groups: ['org_acme']
```
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
```typescript
op.identify({ profileId: 'user_123', email: 'jane@acme.com' });
// Sync group metadata from your backend
op.upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'pro' },
});
op.setGroup('org_acme');
```
## Per-event group override
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
```typescript
op.track('file_shared', {
filename: 'q4-report.pdf',
groups: ['org_acme', 'org_partner'], // Only applies to this event
});
```
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
## Clearing groups on logout
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
```typescript
function handleLogout() {
op.clear();
// redirect to login...
}
```
## Common patterns
### B2B SaaS — company accounts
```typescript
// On login
op.identify({ profileId: user.id, email: user.email });
op.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan, mrr: user.mrr },
});
op.setGroup(user.organizationId);
```
### Multi-tenant — workspaces
```typescript
// When user switches workspace
op.upsertGroup({
id: workspace.id,
type: 'workspace',
name: workspace.name,
});
op.setGroup(workspace.id);
```
### Teams within a company
```typescript
// User belongs to a company and a specific team
op.setGroups([user.organizationId, user.teamId]);
```
## API reference
### `upsertGroup(payload)`
Creates the group if it doesn't exist, or merges properties into the existing group.
```typescript
op.upsertGroup({
id: string; // Required
type: string; // Required
name: string; // Required
properties?: Record<string, unknown>;
});
```
### `setGroup(groupId)`
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
```typescript
op.setGroup('org_acme');
```
### `setGroups(groupIds)`
Same as `setGroup()` but for multiple group IDs at once.
```typescript
op.setGroups(['org_acme', 'team_engineering']);
```
## What to avoid
- **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
- **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
- **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
- **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.

View File

@@ -3,6 +3,7 @@
"install-openpanel",
"track-events",
"identify-users",
"groups",
"revenue-tracking"
]
}

View File

@@ -8,6 +8,7 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
## ✨ Key Features
- **🔍 Advanced Analytics**: [Funnels](/features/funnels), cohorts, user profiles, and session history
- **🎬 Session Replay**: [Record and replay user sessions](/features/session-replay) with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -28,6 +29,7 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -36,10 +38,14 @@ import { UserIcon,HardDriveIcon } from 'lucide-react'
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
✅*** Plausible has simple goals
✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## 🚀 Quick Start
Before you can start tracking your events you'll need to create an account or spin up your own instance of OpenPanel.

View File

@@ -8,9 +8,11 @@
"...(tracking)",
"---API---",
"...api",
"---Dashboard---",
"...dashboard",
"---Self-hosting---",
"...self-hosting",
"---Migration---",
"...migration"
]
}
}

View File

@@ -0,0 +1,251 @@
---
title: High volume setup
description: Tuning OpenPanel for high event throughput
---
import { Callout } from 'fumadocs-ui/components/callout';
The default Docker Compose setup works well for most deployments. When you start seeing high event throughput — thousands of events per second or dozens of worker replicas — a few things need adjusting.
## Connection pooling with PGBouncer
PostgreSQL has a hard limit on the number of open connections. Each worker and API replica opens its own pool of connections, so the total can grow fast. Without pooling, you will start seeing `too many connections` errors under load.
PGBouncer sits in front of PostgreSQL and maintains a small pool of real database connections, multiplexing many application connections on top of them.
### Add PGBouncer to docker-compose.yml
Add the `op-pgbouncer` service and update the `op-api` and `op-worker` dependencies:
```yaml
op-pgbouncer:
image: edoburu/pgbouncer:v1.25.1-p0
restart: always
depends_on:
op-db:
condition: service_healthy
environment:
- DB_HOST=op-db
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=postgres
- DB_NAME=postgres
- AUTH_TYPE=scram-sha-256
- POOL_MODE=transaction
- MAX_CLIENT_CONN=1000
- DEFAULT_POOL_SIZE=20
- MIN_POOL_SIZE=5
- RESERVE_POOL_SIZE=5
healthcheck:
test: ["CMD-SHELL", "PGPASSWORD=postgres psql -h 127.0.0.1 -p 5432 -U postgres pgbouncer -c 'SHOW VERSION;' -q || exit 1"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
Then update `op-api` and `op-worker` to depend on `op-pgbouncer` instead of `op-db`:
```yaml
op-api:
depends_on:
op-pgbouncer:
condition: service_healthy
op-ch:
condition: service_healthy
op-kv:
condition: service_healthy
op-worker:
depends_on:
op-pgbouncer:
condition: service_healthy
op-api:
condition: service_healthy
```
### Update DATABASE_URL
Prisma needs to know it is talking to a pooler. Point `DATABASE_URL` at `op-pgbouncer` and add `&pgbouncer=true`:
```bash
# Before
DATABASE_URL=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
# After
DATABASE_URL=postgresql://postgres:postgres@op-pgbouncer:5432/postgres?schema=public&pgbouncer=true
```
Leave `DATABASE_URL_DIRECT` pointing at `op-db` directly, without the `pgbouncer=true` flag. Migrations use the direct connection and will not work through a transaction-mode pooler.
```bash
DATABASE_URL_DIRECT=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
```
<Callout type="warn">
PGBouncer runs in transaction mode. Prisma migrations and interactive transactions require a direct connection. Always set `DATABASE_URL_DIRECT` to the `op-db` address.
</Callout>
### Tuning the pool size
A rough rule: `DEFAULT_POOL_SIZE` should not exceed your PostgreSQL `max_connections` divided by the number of distinct database/user pairs. The PostgreSQL default is 100. If you raise `max_connections` in Postgres, you can raise `DEFAULT_POOL_SIZE` proportionally.
---
## Buffer tuning
Events, sessions, and profiles flow through in-memory Redis buffers before being written to ClickHouse in batches. The defaults are conservative. Under high load you want larger batches to reduce the number of ClickHouse inserts and improve throughput.
### Event buffer
The event buffer collects incoming events in Redis and flushes them to ClickHouse on a cron schedule.
| Variable | Default | What it controls |
|---|---|---|
| `EVENT_BUFFER_BATCH_SIZE` | `4000` | How many events are read from Redis and sent to ClickHouse per flush |
| `EVENT_BUFFER_CHUNK_SIZE` | `1000` | How many events are sent in a single ClickHouse insert call |
| `EVENT_BUFFER_MICRO_BATCH_MS` | `10` | How long (ms) to accumulate events in memory before writing to Redis |
| `EVENT_BUFFER_MICRO_BATCH_SIZE` | `100` | Max events to accumulate before forcing a Redis write |
For high throughput, increase `EVENT_BUFFER_BATCH_SIZE` so each flush processes more events. Keep `EVENT_BUFFER_CHUNK_SIZE` at or below `EVENT_BUFFER_BATCH_SIZE`.
```bash
EVENT_BUFFER_BATCH_SIZE=10000
EVENT_BUFFER_CHUNK_SIZE=2000
```
### Session buffer
Sessions are updated on each event and flushed to ClickHouse separately.
| Variable | Default | What it controls |
|---|---|---|
| `SESSION_BUFFER_BATCH_SIZE` | `1000` | Events read per flush |
| `SESSION_BUFFER_CHUNK_SIZE` | `1000` | Events per ClickHouse insert |
```bash
SESSION_BUFFER_BATCH_SIZE=5000
SESSION_BUFFER_CHUNK_SIZE=2000
```
### Profile buffer
Profiles are merged with existing data before writing. The default batch size is small because each profile may require a ClickHouse lookup.
| Variable | Default | What it controls |
|---|---|---|
| `PROFILE_BUFFER_BATCH_SIZE` | `200` | Profiles processed per flush |
| `PROFILE_BUFFER_CHUNK_SIZE` | `1000` | Profiles per ClickHouse insert |
| `PROFILE_BUFFER_TTL_IN_SECONDS` | `3600` | How long a profile stays cached in Redis |
Raise `PROFILE_BUFFER_BATCH_SIZE` if profile processing is a bottleneck. Higher values mean fewer flushes but more memory used per flush.
```bash
PROFILE_BUFFER_BATCH_SIZE=500
PROFILE_BUFFER_CHUNK_SIZE=1000
```
---
## Scaling ingestion
If the event queue is growing faster than workers can drain it, you have a few options.
Start vertical before going horizontal. Each worker replica adds overhead: more Redis connections, more ClickHouse connections, more memory. Increasing concurrency on an existing replica is almost always cheaper and more effective than adding another one.
### Increase job concurrency (do this first)
Each worker processes multiple jobs in parallel. The default is `10` per replica.
```bash
EVENT_JOB_CONCURRENCY=20
```
Raise this in steps and watch your queue depth. The limit is memory, not logic — values of `500`, `1000`, or even `2000+` are possible on hardware with enough RAM. Each concurrent job holds event data in memory, so monitor usage as you increase the value. Only add more replicas once concurrency alone stops helping.
### Add more worker replicas
If you have maxed out concurrency and the queue is still falling behind, add more replicas.
In `docker-compose.yml`:
```yaml
op-worker:
deploy:
replicas: 8
```
Or at runtime:
```bash
docker compose up -d --scale op-worker=8
```
### Shard the events queue
<Callout type="warn">
**Experimental.** Queue sharding requires either a Redis Cluster or Dragonfly. Dragonfly has seen minimal testing and Redis Cluster has not been tested at all. Do not use this in production without validating it in your environment first.
</Callout>
Redis is single-threaded, so a single queue instance can become the bottleneck at very high event rates. Queue sharding works around this by splitting the queue across multiple independent shards. Each shard can be backed by its own Redis instance, so the throughput scales with the number of instances rather than being capped by one core.
Events are distributed across shards by project ID, so ordering within a project is preserved.
```bash
EVENTS_GROUP_QUEUES_SHARDS=4
QUEUE_CLUSTER=true
```
<Callout type="warn">
Set `EVENTS_GROUP_QUEUES_SHARDS` before you have live traffic on the queue. Changing it while jobs are pending will cause those jobs to be looked up on the wrong shard and they will not be processed until the shard count is restored.
</Callout>
### Tune the ordering delay
Events arriving out of order are held briefly before processing. The default is `100ms`.
```bash
ORDERING_DELAY_MS=100
```
Lowering this reduces latency but increases the chance of out-of-order writes to ClickHouse. The value should not exceed `500ms`.
---
## Putting it together
A starting point for a high-volume `.env`:
```bash
# Route app traffic through PGBouncer
DATABASE_URL=postgresql://postgres:postgres@op-pgbouncer:5432/postgres?schema=public&pgbouncer=true
# Keep direct connection for migrations
DATABASE_URL_DIRECT=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
# Event buffer
EVENT_BUFFER_BATCH_SIZE=10000
EVENT_BUFFER_CHUNK_SIZE=2000
# Session buffer
SESSION_BUFFER_BATCH_SIZE=5000
SESSION_BUFFER_CHUNK_SIZE=2000
# Profile buffer
PROFILE_BUFFER_BATCH_SIZE=500
# Queue
EVENTS_GROUP_QUEUES_SHARDS=4
EVENT_JOB_CONCURRENCY=20
```
Then start with more workers:
```bash
docker compose up -d --scale op-worker=8
```
Monitor the Redis queue depth and ClickHouse insert latency as you tune. The right values depend on your hardware, event shape, and traffic pattern.

View File

@@ -8,6 +8,7 @@
"[Deploy with Dokploy](/docs/self-hosting/deploy-dokploy)",
"[Deploy on Kubernetes](/docs/self-hosting/deploy-kubernetes)",
"[Environment Variables](/docs/self-hosting/environment-variables)",
"[High volume setup](/docs/self-hosting/high-volume)",
"supporter-access-latest-docker-images",
"changelog"
]

View File

@@ -0,0 +1,166 @@
{
"slug": "session-replay",
"short_name": "Session replay",
"seo": {
"title": "Session Replay - Watch Real User Sessions | OpenPanel",
"description": "Replay real user sessions to understand exactly what happened. Privacy-first session replay with masking controls, unlimited recordings, and 30-day retention.",
"keywords": [
"session replay",
"session recording",
"user session replay",
"hotjar alternative",
"privacy-first session replay"
]
},
"hero": {
"heading": "See exactly what your users did",
"subheading": "Replay any user session to see clicks, scrolls, and interactions. Privacy controls built in. Loaded async so it never slows down your site.",
"badges": [
"Unlimited replays",
"30-day retention",
"Privacy controls built in",
"Async—zero bundle bloat"
]
},
"definition": {
"title": "What is session replay?",
"text": "Session replay lets you watch a structured recording of what a real user did during a visit to your site or app. You see every click, scroll, form interaction, and page navigation—played back in order.\n\nMost analytics tools tell you **what happened in aggregate**: 40% of users dropped off at step 2. Session replay shows you **why**: you can watch someone struggle with a confusing form label, miss a button, or hit an error state you didn't know existed.\n\nOpenPanel session replay is built on [rrweb](https://www.rrweb.io/), an open-source recording library. It captures DOM mutations and user interactions as structured data—not video. This matters because:\n\n- **Privacy is easier to manage** — you control exactly what gets recorded with CSS selectors, not hoping a video blur is accurate\n- **Storage is efficient** — structured data compresses far better than video\n- **Playback is instant** — no buffering or waiting for video to load\n\nSession replay in OpenPanel is **opt-in and off by default**. When disabled, the replay script is never loaded and adds zero bytes to your page. When enabled, the recorder is fetched asynchronously as a separate script, so your main analytics bundle stays lean regardless.\n\nPrivacy controls are first-class:\n\n- **All inputs masked by default** — form field values are never recorded\n- **Block any element** with a data attribute or CSS selector\n- **Mask specific text** without blocking the surrounding layout\n- **Ignore interactions** on sensitive elements\n\nReplays are linked to sessions, events, and user profiles. When a user reports a bug, you can pull up their session in seconds and see exactly what happened—no need to ask them to reproduce it."
},
"capabilities_section": {
"title": "What you get with session replay",
"intro": "Everything you need to understand real user behavior, with privacy controls built in from the start."
},
"capabilities": [
{
"title": "Full session playback",
"description": "Replay any recorded session from start to finish. See clicks, scrolls, form interactions, and navigation in the exact order they happened."
},
{
"title": "Linked to events and profiles",
"description": "Replays are tied to the user's session, event timeline, and profile. Jump from a funnel drop-off directly to the replay for context."
},
{
"title": "Input masking by default",
"description": "All form field values are masked out of the box. You see that a user typed something—not what they typed. Disable per-field if needed."
},
{
"title": "Block and mask controls",
"description": "Block entire elements from recording with a data attribute or CSS selector. Mask specific text. Ignore interactions on sensitive areas."
},
{
"title": "Async loading—zero bundle impact",
"description": "The replay module loads as a separate async script. When replay is disabled it's never fetched. When enabled it doesn't block your main bundle."
},
{
"title": "Unlimited replays, 30-day retention",
"description": "No cap on the number of sessions recorded. Every replay is stored for 30 days and available for playback at any time."
}
],
"screenshots": [
{
"src": "/features/feature-sessions.webp",
"alt": "Session overview showing user sessions with entry pages and duration",
"caption": "Browse all sessions. Click any one to open the replay."
},
{
"src": "/features/feature-sessions-details.webp",
"alt": "Session detail view showing events in order",
"caption": "The session timeline shows every event alongside the replay."
}
],
"how_it_works": {
"title": "How session replay works",
"intro": "Enable it once and every qualifying session is recorded automatically.",
"steps": [
{
"title": "Enable replay in your init config",
"description": "Set `sessionReplay: { enabled: true }` in your OpenPanel init options. That's all the configuration required to start recording."
},
{
"title": "The replay script loads asynchronously",
"description": "When a session starts, OpenPanel fetches the replay module (`op1-replay.js`) as a separate async script. It doesn't block page load or inflate your main bundle."
},
{
"title": "Interactions are captured and sent in chunks",
"description": "The recorder captures DOM changes and user interactions and sends them to OpenPanel in small chunks every 10 seconds and on page unload."
},
{
"title": "Replay any session from the dashboard",
"description": "Open any session in the dashboard and hit play. The replay reconstructs the user's exact experience. Jump to specific events from the timeline."
}
]
},
"use_cases": {
"title": "Who uses session replay",
"intro": "Teams that need to understand real user behavior beyond what metrics alone can show.",
"items": [
{
"title": "Product teams",
"description": "When the data says users drop off at a specific step, session replay shows you exactly why. See confusion, missed CTAs, and error states you didn't know existed."
},
{
"title": "Support and success teams",
"description": "When a user reports a bug or a confusing experience, pull up their session replay in seconds. You see what they saw—no need to ask them to reproduce it."
},
{
"title": "Privacy-conscious teams",
"description": "Input masking is on by default. Block sensitive UI with a data attribute. You get the behavioral insight without recording personal data."
}
]
},
"related_features": [
{
"slug": "session-tracking",
"title": "Session tracking",
"description": "Structured session data—entry pages, event timelines, duration—without requiring replay."
},
{
"slug": "event-tracking",
"title": "Event tracking",
"description": "Custom events appear alongside replays in the session timeline."
},
{
"slug": "funnels",
"title": "Funnels",
"description": "Jump from a funnel drop-off step directly to the session replay to understand why."
}
],
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions about session replay with OpenPanel.",
"items": [
{
"question": "Is session replay enabled by default?",
"answer": "No. Session replay is opt-in. You enable it by setting `sessionReplay: { enabled: true }` in your init config. When disabled, the replay script is never fetched and adds zero overhead to your page."
},
{
"question": "Does enabling session replay slow down my site?",
"answer": "No. The replay module loads as a separate async script (`op1-replay.js`), independent of the main tracking bundle (`op1.js`). It's fetched after the page loads and does not block rendering or the main analytics script."
},
{
"question": "How is this different from Hotjar or FullStory?",
"answer": "Hotjar and FullStory record video-like streams. OpenPanel captures structured DOM events using rrweb. The result looks similar in the viewer, but structured data gives you finer-grained privacy controls (CSS-selector masking, element blocking) and is more storage-efficient. OpenPanel is also open-source and can be self-hosted."
},
{
"question": "Are form inputs recorded?",
"answer": "No. All input field values are masked by default (`maskAllInputs: true`). You see that a user interacted with a field, but not what they typed. You can disable this on a per-field basis if needed."
},
{
"question": "How long are replays stored?",
"answer": "Replays are retained for 30 days. There is no limit on the number of sessions recorded."
},
{
"question": "Can I block specific parts of my UI from being recorded?",
"answer": "Yes. Add `data-openpanel-replay-block` to any element to replace it with a placeholder in the replay. Use `data-openpanel-replay-mask` to mask specific text. Both the attribute names and the CSS selectors they target are configurable."
},
{
"question": "Does session replay work with self-hosted OpenPanel?",
"answer": "Yes. When self-hosting, the replay script is served from your instance automatically. You can also override the script URL with the `scriptUrl` option if you host it on a CDN."
}
]
},
"cta": {
"label": "Start recording sessions",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}

View File

@@ -2,31 +2,32 @@
"slug": "session-tracking",
"short_name": "Session tracking",
"seo": {
"title": "Session Tracking Without Replays - Privacy-First",
"description": "Understand user sessions from entry to exit-without recordings or privacy risk. See pages visited, events fired, and session duration with privacy-first analytics.",
"title": "Session Tracking - Understand User Journeys | OpenPanel",
"description": "Understand user sessions from entry to exit. See pages visited, events fired, and session duration. Optionally add session replay to watch exactly what users did.",
"keywords": [
"session tracking analytics",
"user session tracking",
"session analysis without replay"
"session analysis",
"session replay analytics"
]
},
"hero": {
"heading": "What happened in the session",
"subheading": "Pages visited, events fired, time spent. No recordings, no privacy risk. You still get the full picture.",
"subheading": "Pages visited, events fired, time spent. Full structured session data—and optional session replay when you need to go deeper.",
"badges": [
"No session recordings",
"Privacy-first by design",
"Entry-to-exit visibility",
"Sessions linked to events"
"Sessions linked to events",
"Optional session replay"
]
},
"definition": {
"title": "What is session tracking?",
"text": "A session is the **window of activity** between a user arriving on your site and leaving. It starts with an entry page, includes every page view and event along the way, and ends when the user goes idle or closes the tab.\n\nMost analytics tools either give you **too little** (aggregated page-view counts with no sense of flow) or **too much** (full session recordings that raise privacy concerns and take hours to review). OpenPanel sits in the middle: you get a **structured timeline** of what happened in each session, without recording a single pixel of the user's screen.\n\nFor every session, OpenPanel captures:\n\n- **Entry page and exit page** - where the user started and where they left\n- **Pages visited in order** - the path through your site or app\n- **Events fired** - signups, clicks, feature usage, or any custom event\n- **Session duration** - how long the session lasted\n- **Referrer and UTM parameters** - how the user got there\n- **Device, browser, and location** - context without fingerprinting\n\nThis means you can answer questions like:\n\n- **What pages do users visit before signing up?**\n- **Do users from organic search behave differently than paid traffic?**\n- **How long are sessions for users who convert vs. those who don't?**\n\nUnlike session replay tools (Hotjar, FullStory, LogRocket), there are **no recordings to watch**, **no PII captured on screen**, and **no consent banners** needed for video replay. You get the analytical value of sessions without the privacy overhead.\n\nSessions in OpenPanel connect directly to **events and user profiles**. Every event belongs to a session, and every session belongs to a user. This means funnels, retention, and user timelines all have session context built in."
"text": "A session is the **window of activity** between a user arriving on your site and leaving. It starts with an entry page, includes every page view and event along the way, and ends when the user goes idle or closes the tab.\n\nMost analytics tools either give you **too little** (aggregated page-view counts with no sense of flow) or **too much** (full session recordings that can be slow to review). OpenPanel gives you both options:\n\n- **Session tracking** (always on) — a structured timeline of what happened in each session: pages visited, events fired, duration, referrer, and device context\n- **[Session replay](/features/session-replay)** (opt-in) — a playable recording of the session built on [rrweb](https://www.rrweb.io/), so you can see exactly what the user clicked and where they got confused\n\nFor every session, OpenPanel captures:\n\n- **Entry page and exit page** - where the user started and where they left\n- **Pages visited in order** - the path through your site or app\n- **Events fired** - signups, clicks, feature usage, or any custom event\n- **Session duration** - how long the session lasted\n- **Referrer and UTM parameters** - how the user got there\n- **Device, browser, and location** - context without fingerprinting\n\nThis means you can answer questions like:\n\n- **What pages do users visit before signing up?**\n- **Do users from organic search behave differently than paid traffic?**\n- **How long are sessions for users who convert vs. those who don't?**\n\nSession replay is **opt-in and off by default**. When disabled, the replay script is never loaded and adds no overhead. When enabled, it loads asynchronously as a separate script so your main analytics bundle stays lean.\n\nSessions in OpenPanel connect directly to **events and user profiles**. Every event belongs to a session, and every session belongs to a user. This means funnels, retention, and user timelines all have session context built in."
},
"capabilities_section": {
"title": "What you get with session tracking",
"intro": "Structured session data that answers real questions-without the privacy cost of recordings."
"intro": "Structured session data that answers real questionswith optional replay when you need to see the full picture."
},
"capabilities": [
{
@@ -35,7 +36,7 @@
},
{
"title": "Page flow per session",
"description": "View the ordered sequence of pages a user visited in a session. Understand navigation patterns without watching a recording."
"description": "View the ordered sequence of pages a user visited in a session. Understand navigation patterns at a glance."
},
{
"title": "Events within a session",
@@ -50,8 +51,8 @@
"description": "Know how users arrived-organic search, paid campaign, direct link-and compare session quality across sources."
},
{
"title": "Device and location context",
"description": "Capture browser, OS, and approximate location for each session. No fingerprinting-just standard request headers."
"title": "Session replay (opt-in)",
"description": "Enable session replay to record and play back real user sessions. Privacy controls built ininputs masked by default. Loads async so it never bloats your bundle."
}
],
"screenshots": [
@@ -63,7 +64,7 @@
{
"src": "/features/feature-sessions-details.webp",
"alt": "Session events timeline showing user actions in order",
"caption": "Every event tied to its session. Understand user journeys without replay tools."
"caption": "Every event tied to its session. Drill into the timeline or open the replay."
}
],
"how_it_works": {
@@ -80,29 +81,34 @@
},
{
"title": "Sessions connect to everything",
"description": "Each session links to the user profile, the events fired, and the pages visited. This means funnels, retention, and user timelines all include session context."
"description": "Each session links to the user profile, the events fired, and the pages visited. Enable session replay to also record a playable video of the session."
}
]
},
"use_cases": {
"title": "Who uses session tracking",
"intro": "Teams that need to understand user journeys without the overhead of session replays.",
"intro": "Teams that need to understand user journeys, from structured data to full session replay.",
"items": [
{
"title": "Product teams",
"description": "Understand how users navigate your product. See the page flow and events in a session to identify friction points-without watching hours of recordings."
"description": "Understand how users navigate your product. See the page flow and events in a session, then open a replay to see exactly where users got stuck."
},
{
"title": "Support and success teams",
"description": "When a user reports an issue, pull up their recent sessions to see what pages they visited and what events they triggered. Context without asking \"can you describe what you did?\""
"description": "When a user reports an issue, pull up their recent sessions to see what pages they visited and what events they triggered. Open the replay for the full picture."
},
{
"title": "Privacy-conscious teams",
"description": "Get session-level insights without recording user screens. No PII in screenshots, no video consent banners, no GDPR headaches from replay data."
"description": "Session tracking works without cookies or recordings. Session replay is opt-in, with inputs masked by default and granular controls to block or mask any sensitive element."
}
]
},
"related_features": [
{
"slug": "session-replay",
"title": "Session replay",
"description": "Watch real user sessions. See clicks, scrolls, and form interactions played back in the dashboard."
},
{
"slug": "event-tracking",
"title": "Event tracking",
@@ -119,8 +125,8 @@
"intro": "Common questions about session tracking with OpenPanel.",
"items": [
{
"question": "How is this different from session replay tools like Hotjar or FullStory?",
"answer": "Session replay tools record a video of the user's screen. OpenPanel doesn't record anything visual-it tracks structured data: which pages were visited, which events were triggered, and how long the session lasted. You get the analytical answers without the privacy cost or the hours spent watching recordings."
"question": "Does OpenPanel have session replay?",
"answer": "Yes. Session replay is available as an opt-in feature. Enable it by setting `sessionReplay: { enabled: true }` in your init config. When disabled, the replay script is never loaded. See the [session replay docs](/docs/session-replay) for setup and privacy options."
},
{
"question": "Do I need to set up session tracking separately?",
@@ -132,7 +138,7 @@
},
{
"question": "Can I see individual user sessions?",
"answer": "Yes. You can view a user's session history, including the pages they visited and events they triggered in each session. This is available in the user profile view."
"answer": "Yes. You can view a user's session history, including the pages they visited and events they triggered in each session. This is available in the user profile view. If session replay is enabled, you can also play back the session."
},
{
"question": "Does session tracking require cookies?",
@@ -144,4 +150,4 @@
"label": "Start tracking sessions",
"href": "https://dashboard.openpanel.dev/onboarding"
}
}
}

View File

@@ -0,0 +1,155 @@
{
"slug": "nextjs",
"audience": "nextjs",
"seo": {
"title": "Next.js Analytics — OpenPanel SDK for App Router & Pages Router",
"description": "Add analytics to your Next.js app in minutes. OpenPanel's Next.js SDK supports App Router, Pages Router, server-side events, and automatic route change tracking. Open source, cookieless, from $2.50/month.",
"noindex": false
},
"hero": {
"heading": "Analytics Built for Next.js",
"subheading": "Most analytics tools treat Next.js like a static site. OpenPanel was built for how Next.js actually works — App Router, server components, API routes, and route changes all tracked correctly. Install in 5 minutes, get events, funnels, and user profiles that work with SSR.",
"badges": [
"App Router support",
"Server-side events",
"Auto route tracking",
"2.3 KB client bundle"
]
},
"problem": {
"title": "Why standard analytics breaks in Next.js",
"intro": "Next.js is not a traditional website. Most analytics tools were built before App Router existed and show it.",
"items": [
{
"title": "GA4 breaks with App Router route changes",
"description": "Google Analytics was designed for traditional page loads. In Next.js App Router, client-side navigation doesn't trigger GA4's page view events unless you add custom workarounds. Your analytics misses a significant portion of page views."
},
{
"title": "Cookie consent is painful in Next.js",
"description": "Implementing a GDPR-compliant cookie consent flow in a Next.js app requires next/script, consent state management, and conditional script loading. It's a non-trivial engineering task just to run analytics."
},
{
"title": "No server-side tracking",
"description": "Most analytics SDKs are client-only. Server-side events — API route completions, background jobs, server actions — are invisible. You're missing half the picture."
},
{
"title": "Analytics libraries bloat your bundle",
"description": "Many analytics SDKs add 2050 KB to your JavaScript bundle. In a performance-conscious Next.js app, that overhead matters for Core Web Vitals."
}
]
},
"features": {
"title": "Analytics that works the way Next.js works",
"intro": "OpenPanel's Next.js SDK was designed for modern Next.js patterns — not adapted from a legacy client-only library.",
"items": [
{
"title": "Next.js SDK with App Router support",
"description": "Install @openpanel/nextjs, add the <OpenPanelComponent> to your root layout, and automatic route change tracking works immediately across App Router and Pages Router."
},
{
"title": "Automatic route change tracking",
"description": "Every router.push(), <Link> navigation, and back/forward browser action is captured as a page view. No custom useEffect or router event listeners needed."
},
{
"title": "Server-side event tracking",
"description": "Import openPanel in any server component, API route, or server action to track events server-side. Track form submissions, payment completions, and background jobs without any client involvement."
},
{
"title": "Cookieless by default",
"description": "The OpenPanel SDK uses no cookies. No consent management library, no conditional script loading, no GDPR consent modal needed — just install and track."
},
{
"title": "Identify users across sessions",
"description": "Call op.identify({ profileId, name, email }) after authentication to tie anonymous events to known users. Works with any auth solution including NextAuth.js, Clerk, and custom implementations."
},
{
"title": "TypeScript-first SDK",
"description": "Full TypeScript types for all methods. Autocomplete for event names and properties. Zero runtime errors from mistyped event calls."
},
{
"title": "2.3 KB gzipped client bundle",
"description": "The smallest full-featured analytics SDK for Next.js. No impact on Lighthouse scores or Core Web Vitals."
}
]
},
"benefits": {
"title": "Why Next.js developers choose OpenPanel",
"intro": "OpenPanel fits naturally into a modern Next.js codebase — not as an afterthought.",
"items": [
{
"title": "Works exactly how Next.js works",
"description": "Built for App Router, server components, and modern Next.js patterns. Not bolted onto a client-only library."
},
{
"title": "No consent infrastructure needed",
"description": "Cookieless tracking means no consent modal, no next/script loading gymnastics, no conditional initialization. Install once, works everywhere."
},
{
"title": "Track server and client events in the same dashboard",
"description": "Server actions, API endpoints, and client interactions all show up together. Full picture of what your app is doing."
},
{
"title": "Open source and self-hostable",
"description": "Run OpenPanel on your own infrastructure. Your Next.js app's analytics data never leaves your servers."
},
{
"title": "From $2.50/month",
"description": "No enterprise contract, no per-seat fees. Pay for events, get all features. Start free with 30-day trial."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from Next.js developers evaluating OpenPanel.",
"items": [
{
"question": "How do I install OpenPanel in a Next.js app?",
"answer": "Install @openpanel/nextjs, add <OpenPanelComponent clientId=\"...\" /> to your root layout.tsx, and you're tracking page views. For custom events, import and call op.track('event_name', { properties }) anywhere. See the full step-by-step guide at /guides/nextjs-analytics."
},
{
"question": "Does OpenPanel support Next.js App Router?",
"answer": "Yes. The <OpenPanelComponent> handles App Router route changes automatically using the usePathname hook internally. No custom setup is needed for client-side navigation tracking."
},
{
"question": "Can I track events in Server Components and API Routes?",
"answer": "Yes. Import { openPanel } from @openpanel/nextjs/server and call openPanel.track() in any server context including Server Components, Route Handlers, and Server Actions."
},
{
"question": "Does OpenPanel work with NextAuth.js or Clerk?",
"answer": "Yes. Call op.identify({ profileId: session.user.id, ... }) after authentication to link events to user identities. Works with any auth solution that gives you a user ID."
},
{
"question": "Does OpenPanel add significant bundle size to my Next.js app?",
"answer": "No. The client-side SDK is 2.3 KB gzipped. For reference, GA4 adds 50+ KB. OpenPanel has negligible impact on your bundle size or Core Web Vitals."
},
{
"question": "Can I self-host OpenPanel for my Next.js app?",
"answer": "Yes. OpenPanel is fully open source and can be self-hosted with Docker. Your Next.js app sends events to your own server. The self-hosted version has no event limits and is free to run."
}
]
},
"related_links": {
"guides": [
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
{ "title": "React analytics setup", "url": "/guides/react-analytics" },
{ "title": "Track custom events", "url": "/guides/track-custom-events" }
],
"articles": [
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,165 @@
{
"slug": "saas",
"audience": "saas",
"seo": {
"title": "SaaS Analytics — Track Events, Funnels & Retention Without Enterprise Pricing",
"description": "OpenPanel gives SaaS teams the product analytics they need to reduce churn and grow — events, funnels, retention, cohorts, and user profiles. Open source, from $2.50/month.",
"noindex": false
},
"hero": {
"heading": "Product Analytics for SaaS Teams",
"subheading": "Understanding why users churn, where trials drop off, and which features drive retention is the difference between a SaaS that grows and one that plateaus. OpenPanel gives you events, funnels, retention analysis, and user profiles — the core analytics stack for SaaS — without Mixpanel or Amplitude's enterprise pricing.",
"badges": [
"Funnel analysis",
"Retention tracking",
"User profiles",
"From $2.50/month"
]
},
"problem": {
"title": "Why SaaS teams outgrow their analytics tools",
"intro": "The analytics tools most SaaS teams start with either can't answer the right questions or become unaffordable as you grow.",
"items": [
{
"title": "Mixpanel and Amplitude become unaffordable at scale",
"description": "Event-based pricing sounds cheap at 10K events, but SaaS products are event-heavy. At 1M events/month you're paying $300800/month. At 10M, it's thousands. Your analytics bill grows faster than your revenue."
},
{
"title": "GA4 doesn't answer SaaS questions",
"description": "Google Analytics tells you pageviews. It can't tell you which users completed your onboarding flow, how feature adoption correlates with retention, or why your trial-to-paid conversion dropped last month."
},
{
"title": "Complex tools slow down your team",
"description": "Mixpanel's interface is powerful but has a steep learning curve. When it takes 30 minutes to build a simple funnel, your team stops using analytics to make decisions."
},
{
"title": "Cloud-only means full vendor dependence",
"description": "If Mixpanel raises prices, your data is hostage. No export path, no self-hosting option, and a pricing model that punishes growth."
}
]
},
"features": {
"title": "The full product analytics stack for SaaS",
"intro": "Everything your team needs to understand users, reduce churn, and grow — without the enterprise pricing.",
"items": [
{
"title": "Event tracking",
"description": "Track any user action — feature used, button clicked, settings changed, plan upgraded. One line of code. Works across web, mobile, and your backend."
},
{
"title": "Funnel analysis",
"description": "Build conversion funnels for trial signup, onboarding, activation, and upgrade flows. See the exact step losing you the most conversions and fix it."
},
{
"title": "Retention analysis",
"description": "Cohort-based retention charts show you whether users activated in week 1 are still active in week 8. Identify which actions predict long-term retention."
},
{
"title": "User profiles",
"description": "See every event a specific user triggered since they signed up. Walk through their session to diagnose support issues or understand power user behavior."
},
{
"title": "Cohort analysis",
"description": "Group users by signup date, plan, or any property and compare their behavior over time. Understand how product changes affect different user segments."
},
{
"title": "Real-time dashboard",
"description": "See new signups, trial activations, and feature usage as they happen. Know immediately when a new deployment changes user behavior."
},
{
"title": "Revenue tracking",
"description": "Send MRR, payment events, and plan upgrade data to OpenPanel. Correlate feature usage with revenue without a separate BI tool."
},
{
"title": "Multi-platform SDKs",
"description": "Track across web app, marketing site, iOS, Android, and backend in one unified analytics view. Events from all platforms share the same user profiles."
}
]
},
"benefits": {
"title": "Why SaaS teams choose OpenPanel",
"intro": "OpenPanel gives you the analytics depth of Mixpanel at a price that makes sense for growing SaaS products.",
"items": [
{
"title": "Predictable pricing as you scale",
"description": "Flat event tiers from $2.50 to $900/month. No per-seat fees, no MTU limits. Know exactly what you'll pay as your user base grows."
},
{
"title": "Answer the questions SaaS teams actually ask",
"description": "Which onboarding step has the highest drop-off? Which features are used by retained users vs churned users? OpenPanel is built for these questions."
},
{
"title": "Self-host to eliminate per-event costs",
"description": "Large SaaS products generate millions of events. Self-hosting eliminates the cost entirely — pay only for your server infrastructure."
},
{
"title": "All features from day one",
"description": "Funnels, retention, cohorts, user profiles, and dashboards are available at every pricing tier. No feature gating that forces upgrades."
},
{
"title": "Open source and auditable",
"description": "Your product data is sensitive. OpenPanel's open source codebase means you can audit exactly what's tracked and how it's stored."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from SaaS founders and product teams evaluating OpenPanel.",
"items": [
{
"question": "How is OpenPanel different from Mixpanel for SaaS?",
"answer": "OpenPanel covers the same core analytics (events, funnels, retention, cohorts, user profiles) at a fraction of the cost. Mixpanel adds features like Metric Trees and advanced experimentation, but most SaaS teams don't use them. OpenPanel focuses on the analytics that actually drive decisions."
},
{
"question": "Can OpenPanel replace both GA4 and Mixpanel?",
"answer": "Yes. OpenPanel includes web analytics (pageviews, referrers, UTM campaigns) alongside product analytics. Most SaaS teams can replace both tools with one OpenPanel deployment."
},
{
"question": "How do I track trial-to-paid conversions?",
"answer": "Track a trial_started event on signup and a plan_upgraded event on conversion. Build a funnel in OpenPanel between those two events. See conversion rate by cohort, traffic source, or any user property."
},
{
"question": "Does OpenPanel support multi-product or multi-tenant SaaS?",
"answer": "Yes. Use the profileId to identify individual users and pass organization or workspace IDs as properties. You can filter analytics by any property you send."
},
{
"question": "What happens to my data if I stop paying for OpenPanel cloud?",
"answer": "You can export all your event data via the API at any time. Alternatively, migrate to self-hosting — OpenPanel has no lock-in and no proprietary data format."
},
{
"question": "Can I track backend events from my SaaS API?",
"answer": "Yes. OpenPanel has server-side SDKs for Node.js, Python, and a REST API. Track subscription webhooks, background jobs, and server-side logic alongside client events."
},
{
"question": "Is OpenPanel GDPR compliant for SaaS?",
"answer": "Yes. Cookieless tracking by default, EU-only cloud hosting, and the option to self-host for complete data sovereignty. No consent banner required for product analytics."
}
]
},
"related_links": {
"guides": [
{ "title": "Track custom events", "url": "/guides/track-custom-events" },
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
{ "title": "React analytics setup", "url": "/guides/react-analytics" }
],
"articles": [
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,158 @@
{
"slug": "shopify",
"audience": "shopify",
"seo": {
"title": "Shopify Analytics — Cookie-Free Tracking Without Consent Banners",
"description": "Add product analytics to your Shopify store without GDPR consent banners. OpenPanel is cookieless by default — track events, funnels, and revenue from $2.50/month or free to self-host.",
"noindex": false
},
"hero": {
"heading": "Shopify Analytics That Actually Works",
"subheading": "Most analytics tools break on Shopify — cookie consent blocks tracking, GA4 loses attribution, and Shopify's built-in reports can't answer the questions that matter. OpenPanel tracks every event without cookies, so your data is complete and your checkout conversion doesn't suffer from a consent banner.",
"badges": [
"Cookie-free tracking",
"No consent banner needed",
"Revenue tracking",
"From $2.50/month"
]
},
"problem": {
"title": "Why analytics breaks on Shopify",
"intro": "Every major analytics tool creates a different problem for Shopify stores. Here's what you're actually dealing with.",
"items": [
{
"title": "GA4 consent mode destroys your data",
"description": "Cookie consent requirements mean 30-50% of visitors opt out. GA4's \"consent mode\" fills gaps with modeled data — you're making decisions on estimates, not reality."
},
{
"title": "Shopify's built-in analytics is shallow",
"description": "Pageviews and sales are there, but you can't build funnels, analyze drop-off by product page, or see which acquisition channel retains customers best."
},
{
"title": "Event tracking requires a developer",
"description": "Installing GA4 on Shopify correctly, with purchase events, add-to-cart, and checkout steps, requires custom code or expensive third-party apps."
},
{
"title": "Cookie banners hurt conversion",
"description": "A consent popup before checkout is a conversion killer. Every analytics tool that uses cookies forces you to choose between data and revenue."
}
]
},
"features": {
"title": "Everything you need to understand your Shopify store",
"intro": "OpenPanel is designed to give you complete visibility into your store's performance — without the privacy headaches.",
"items": [
{
"title": "Script tag install — no developer needed",
"description": "Add one script tag in Shopify's theme settings. Automatic page view tracking starts immediately. No Shopify app or coding required."
},
{
"title": "Cookieless tracking by default",
"description": "No cookies means no consent banner. Your analytics data is 100% complete — not modeled, not estimated, not filtered by opt-outs."
},
{
"title": "Purchase and revenue tracking",
"description": "Track add-to-cart, checkout started, and purchase events with revenue values. See exactly which campaigns and pages drive revenue, not just clicks."
},
{
"title": "Funnel analysis",
"description": "Build funnels from product page → add to cart → checkout → purchase. See exactly where you're losing customers and fix it."
},
{
"title": "UTM campaign attribution",
"description": "Track every paid ad, email campaign, and influencer link. See which traffic source actually converts to buyers, not just visitors."
},
{
"title": "User profiles",
"description": "See the complete journey of any customer — pages visited, products viewed, purchase history. Identify high-value customer behavior patterns."
},
{
"title": "Real-time dashboard",
"description": "Watch your store live. Launch a sale or send an email and see the traffic surge in real time."
},
{
"title": "EU data residency",
"description": "All data stored in the EU. No transfers to US servers. GDPR compliance without the legal complexity."
}
]
},
"benefits": {
"title": "Why Shopify store owners choose OpenPanel",
"intro": "OpenPanel was built to solve the exact problems that plague analytics on Shopify.",
"items": [
{
"title": "Complete data, no consent required",
"description": "Cookieless tracking means every visitor is counted. No consent mode models, no sampling, no gaps from opt-outs."
},
{
"title": "Cheaper than Shopify analytics apps",
"description": "Most Shopify analytics apps charge $50200/month. OpenPanel starts at $2.50/month with all features included."
},
{
"title": "Replace GA4 and your analytics app",
"description": "One tool covers web analytics (traffic, referrers, UTM) and product analytics (funnels, retention, revenue). Cancel two subscriptions."
},
{
"title": "Privacy-first out of the box",
"description": "EU hosting, no cookies, no fingerprinting. No compliance team required to use OpenPanel on your store."
},
{
"title": "Self-host for free",
"description": "For high-volume stores, self-hosting eliminates per-event costs entirely. One Docker deployment, unlimited events."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from Shopify store owners evaluating OpenPanel.",
"items": [
{
"question": "How do I install OpenPanel on Shopify?",
"answer": "Add the OpenPanel script tag via Shopify Admin → Online Store → Themes → Edit Code → theme.liquid. Paste the snippet before </head>. Automatic page views track immediately. For purchase events, add a few lines to your order confirmation page."
},
{
"question": "Does OpenPanel work without a cookie consent banner?",
"answer": "Yes. OpenPanel is cookieless by default and doesn't collect personal data. No consent banner is required under GDPR or CCPA for analytics-only use."
},
{
"question": "Can I track Shopify purchase events?",
"answer": "Yes. Use a simple script on your order confirmation page to send purchase amount, order ID, and product data to OpenPanel. No Shopify app required."
},
{
"question": "How does OpenPanel compare to Lucky Orange or Hotjar for Shopify?",
"answer": "Lucky Orange and Hotjar focus on heatmaps and session replay. OpenPanel focuses on event analytics, funnels, and retention — understanding what users do, not watching recordings. If you need both, they complement each other."
},
{
"question": "Will OpenPanel affect my Shopify store's page speed?",
"answer": "OpenPanel's JavaScript SDK is 2.3 KB gzipped — one of the lightest analytics trackers available. GA4 is 50+ KB. The performance impact is negligible."
},
{
"question": "Does OpenPanel work with Shopify Plus?",
"answer": "Yes. OpenPanel works on any Shopify plan including Plus. The script tag installation method works identically across all plans."
}
]
},
"related_links": {
"guides": [
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" }
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,157 @@
{
"slug": "wordpress",
"audience": "wordpress",
"seo": {
"title": "WordPress Analytics Without Cookies — A Google Analytics Alternative",
"description": "Add privacy-first analytics to your WordPress site without cookie consent banners. OpenPanel is cookieless, open source, and starts at $2.50/month — a better alternative to GA4 for WordPress.",
"noindex": false
},
"hero": {
"heading": "WordPress Analytics Without the Cookie Banner",
"subheading": "Google Analytics on WordPress means cookie consent popups, GDPR complexity, and sending your visitors' data to US servers. OpenPanel is a cookieless WordPress analytics plugin that gives you pageviews, events, and user behavior — with full GDPR compliance out of the box and none of the privacy headaches.",
"badges": [
"No cookie banner needed",
"GDPR compliant",
"Open source",
"Lightweight — 2.3 KB"
]
},
"problem": {
"title": "Why Google Analytics on WordPress creates problems",
"intro": "WordPress powers 40% of the web, but GA4 was not designed with WordPress privacy requirements in mind.",
"items": [
{
"title": "Google Analytics requires cookie consent on WordPress",
"description": "Installing GA4 on a WordPress site means a consent popup for every visitor. Under GDPR, you can't set GA4 cookies without explicit opt-in. That banner costs you conversions and corrupts your data."
},
{
"title": "GA4 sends data to US servers",
"description": "Google Analytics transfers visitor data to US servers. For European sites, this creates legal exposure under SCHREMS II — a risk WordPress site owners increasingly can't ignore."
},
{
"title": "WordPress analytics plugins are bloated",
"description": "Most GA4 WordPress plugins add significant weight to your site. Page speed suffers, Core Web Vitals scores drop, and you're still dealing with cookies."
},
{
"title": "You can't see user behavior, only pageviews",
"description": "GA4 tells you how many people visited. OpenPanel tells you what they did: which CTAs they clicked, where they dropped off, which blog posts convert to signups."
}
]
},
"features": {
"title": "Privacy-first analytics for WordPress",
"intro": "OpenPanel gives WordPress site owners complete visibility without the cookie compliance headaches.",
"items": [
{
"title": "Official WordPress plugin",
"description": "Install the OpenPanel WordPress plugin directly from the WordPress plugin directory. Activate it, paste your Client ID, and page view tracking starts immediately — no code required."
},
{
"title": "Automatic page view tracking",
"description": "Every WordPress page, post, and custom post type is tracked automatically. No configuration needed for standard pageview analytics."
},
{
"title": "Cookie-free by default",
"description": "No cookies means no consent banner. Compliant with GDPR, CCPA, and ePrivacy without any configuration."
},
{
"title": "Custom event tracking",
"description": "Track clicks, form submissions, and conversions with a one-line JavaScript call. Know which CTAs and forms drive the most leads."
},
{
"title": "UTM campaign tracking",
"description": "See exactly which emails, ads, and social posts drive traffic to your WordPress site — and which ones actually convert."
},
{
"title": "Lightweight script — 2.3 KB",
"description": "OpenPanel's tracker is 20x smaller than GA4. No impact on Core Web Vitals or PageSpeed scores."
},
{
"title": "Self-hostable on your own server",
"description": "Run OpenPanel on your own infrastructure. Your WordPress visitor data never leaves your servers."
}
]
},
"benefits": {
"title": "Why WordPress site owners switch to OpenPanel",
"intro": "OpenPanel replaces Google Analytics without sacrificing features — and fixes the privacy problems GA4 created.",
"items": [
{
"title": "No consent banner required",
"description": "Cookieless tracking means you're GDPR-compliant without a popup. No lost conversions, no fragmented data from opt-outs."
},
{
"title": "Replace Google Analytics without losing features",
"description": "Pageviews, referrers, countries, devices, UTM campaigns — all there. Plus events and funnels that GA4 requires complex configuration to support."
},
{
"title": "Your data stays out of Google's hands",
"description": "OpenPanel is EU-hosted with no data sharing. Ideal for European sites, publishers, and anyone avoiding Google's data monopoly."
},
{
"title": "Open source and auditable",
"description": "Unlike GA4, OpenPanel's code is public. You can see exactly what data is collected and how it's processed."
},
{
"title": "Affordable pricing",
"description": "From $2.50/month for small WordPress sites. Self-host for free if you prefer — full feature parity, no event limits."
}
]
},
"faqs": {
"title": "Frequently asked questions",
"intro": "Common questions from WordPress site owners evaluating OpenPanel.",
"items": [
{
"question": "How do I install OpenPanel on WordPress?",
"answer": "Add the OpenPanel script tag to your WordPress site via your theme's header.php, a child theme, or any \"header scripts\" plugin. Paste the snippet before </head>. Automatic page view tracking starts immediately — no additional configuration needed."
},
{
"question": "Do I need a cookie consent banner with OpenPanel?",
"answer": "No. OpenPanel uses cookieless tracking and doesn't collect personal data by default. Under GDPR, cookie consent is only required when you actually use cookies. OpenPanel doesn't, so no banner is needed."
},
{
"question": "Is there an OpenPanel WordPress plugin?",
"answer": "Yes. The official OpenPanel plugin is available in the WordPress plugin directory. Search for \"OpenPanel\" in Plugins → Add New, install it, and paste your Client ID. You can also add the script tag manually via your theme's header.php or any \"add code to header\" plugin if you prefer."
},
{
"question": "How does OpenPanel compare to MonsterInsights or Analytify?",
"answer": "Those plugins are wrappers around Google Analytics — they still send data to Google and still require cookies. OpenPanel is an independent analytics platform that's cookieless by default and stores data in the EU."
},
{
"question": "Can I track WooCommerce events?",
"answer": "Yes. You can track add-to-cart, checkout, and purchase events by adding a few lines of JavaScript to your WooCommerce templates or using WordPress hooks. No dedicated WooCommerce plugin required."
},
{
"question": "Does OpenPanel affect WordPress site speed?",
"answer": "OpenPanel's script is 2.3 KB gzipped. By comparison, GA4 adds 50+ KB. Switching from GA4 to OpenPanel will likely improve your Core Web Vitals scores."
}
]
},
"related_links": {
"guides": [
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" },
{ "title": "Website analytics setup", "url": "/guides/website-analytics-setup" },
{ "title": "OpenPanel WordPress plugin", "url": "https://sv.wordpress.org/plugins/openpanel/" }
],
"articles": [
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
{ "title": "How to self-host OpenPanel", "url": "/articles/self-hosted-web-analytics" }
],
"comparisons": [
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
]
},
"ctas": {
"primary": {
"label": "Try OpenPanel Free",
"href": "https://dashboard.openpanel.dev/onboarding"
},
"secondary": {
"label": "View Source on GitHub",
"href": "https://github.com/Openpanel-dev/openpanel"
}
}
}

View File

@@ -0,0 +1,233 @@
---
title: "Consent management with OpenPanel"
description: "Learn how to queue analytics events and session replays until the user gives consent, then flush everything at once with a single call."
difficulty: beginner
timeToComplete: 15
date: 2026-03-02
updated: 2026-03-02
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Initialise with disabled: true"
anchor: "disable"
- name: "Build a consent banner"
anchor: "banner"
- name: "Call ready() on consent"
anchor: "ready"
- name: "Handle decline"
anchor: "decline"
- name: "Persist consent across page loads"
anchor: "persist"
---
# Consent management with OpenPanel
Privacy regulations like GDPR and CCPA require that you obtain explicit user consent before tracking behaviour or recording sessions. This guide shows how to use OpenPanel's built-in queue to hold all tracking until the user makes a choice, then flush everything at once—or discard it silently on decline.
## Prerequisites
- OpenPanel installed via the `@openpanel/web` npm package or the script tag
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
## Initialise with `disabled: true` [#disable]
Pass `disabled: true` when creating the OpenPanel instance. All tracking calls (`track`, `identify`, `screenView`, session replay chunks) are held in an in-memory queue instead of being sent to the API.
### Script tag
```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,
disabled: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: true,
});
```
From this point on, any `op.track(...)` calls elsewhere in your app are safely queued and not transmitted.
## Build a consent banner [#banner]
How you build the UI is up to you. The key is to call `op.ready()` when the user accepts, and do nothing (or call `op.clear()`) when they decline.
```tsx title="ConsentBanner.tsx"
import { op } from './op';
export function ConsentBanner() {
function handleAccept() {
localStorage.setItem('consent', 'granted');
op.ready(); // flushes the queue and enables all future tracking
hideBanner();
}
function handleDecline() {
localStorage.setItem('consent', 'denied');
hideBanner(); // queue is discarded on page unload
}
return (
<div role="dialog" aria-label="Cookie consent">
<p>
We use analytics to improve our product. Do you consent to anonymous
usage tracking?
</p>
<button type="button" onClick={handleAccept}>Accept</button>
<button type="button" onClick={handleDecline}>Decline</button>
</div>
);
}
```
## Call `ready()` on consent [#ready]
`op.ready()` does two things:
1. Clears the `disabled` flag so all future events are sent immediately
2. Flushes the entire queue — every event and session replay chunk buffered since page load is sent at once
This means you don't lose any events that happened before the user made their choice. The screen view for the page they landed on, any clicks they made while the banner was visible—all of it is captured and sent the moment they consent.
## Handle session replay [#replay]
If you have session replay enabled, the recorder starts capturing DOM changes as soon as the page loads (so no interactions are missed), but no data leaves the browser until `ready()` is called.
```ts title="op.ts"
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: true,
sessionReplay: {
enabled: true,
},
});
```
On `op.ready()`, buffered replay chunks flush along with the queued events. The full session from the start of the page load is preserved.
## Handle decline [#decline]
If the user declines, don't call `ready()`. The queue lives only in memory and is automatically discarded when the tab closes or the page navigates away. No data is ever sent.
If you want to be explicit, you can clear the queue immediately:
```ts
function handleDecline() {
localStorage.setItem('consent', 'denied');
// op stays disabled — nothing will be sent
// The in-memory queue will be garbage collected
}
```
## Persist consent across page loads [#persist]
The `disabled` flag resets on every page load. You need to check the stored consent choice on initialisation and skip `disabled: true` if consent was already granted.
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const hasConsent = localStorage.getItem('consent') === 'granted';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: !hasConsent,
});
```
And in your banner component, only show it when no choice has been stored:
```tsx title="ConsentBanner.tsx"
export function ConsentBanner() {
const stored = localStorage.getItem('consent');
if (stored) return null; // already decided, don't show
// ... render banner
}
```
## Full example
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const hasConsent = localStorage.getItem('consent') === 'granted';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
disabled: !hasConsent,
sessionReplay: {
enabled: true,
},
});
```
```tsx title="ConsentBanner.tsx"
import { op } from './op';
export function ConsentBanner() {
if (localStorage.getItem('consent')) return null;
return (
<div role="dialog" aria-label="Cookie consent">
<p>We use analytics to improve our product. Do you consent?</p>
<button
type="button"
onClick={() => {
localStorage.setItem('consent', 'granted');
op.ready();
}}
>
Accept
</button>
<button
type="button"
onClick={() => {
localStorage.setItem('consent', 'denied');
}}
>
Decline
</button>
</div>
);
}
```
## Related
- [Consent management docs](/docs/consent-management) — quick reference for `disabled` and `waitForProfile`
- [Session replay](/docs/session-replay) — privacy controls for what gets recorded
- [Identify users](/docs/get-started/identify-users) — link events to a user profile
<Faqs>
<FaqItem question="Are events lost if the user declines?">
No events are sent if the user declines and you never call `ready()`. The queue lives in memory and is discarded when the page unloads.
</FaqItem>
<FaqItem question="What happens to events tracked before the banner appears?">
They sit in the queue. If the user later accepts, they are all flushed. If the user declines, they are discarded.
</FaqItem>
<FaqItem question="Does session replay start before consent?">
The recorder starts capturing DOM changes immediately so the full session can be reconstructed, but nothing is transmitted until `ready()` is called.
</FaqItem>
<FaqItem question="Do I need to handle this differently for the script tag vs npm?">
No. The `disabled` option and `ready()` method work the same in both.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,245 @@
---
title: "How to add session replay to your website"
description: "Add privacy-first session replay to any site in minutes using OpenPanel. See exactly what users do without recording sensitive data."
difficulty: beginner
timeToComplete: 10
date: 2026-02-27
updated: 2026-02-27
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install OpenPanel"
anchor: "install"
- name: "Enable session replay"
anchor: "enable"
- name: "Configure privacy controls"
anchor: "privacy"
- name: "View replays in the dashboard"
anchor: "view"
---
# How to add session replay to your website
This guide walks you through enabling [session replay](/features/session-replay) with OpenPanel. By the end, you'll be recording real user sessions you can play back in the dashboard to understand exactly what your users did.
Session replay captures clicks, scrolls, and interactions as structured data—not video. Privacy controls are built in, and the replay module loads asynchronously so it never slows down your main analytics.
## Prerequisites
- An OpenPanel account
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
- Either the `@openpanel/web` npm package installed, or access to add a script tag to your site
## Install OpenPanel [#install]
If you're starting fresh, add the OpenPanel script tag to your page. If you already have OpenPanel installed, skip to the next step.
```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,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
Or with npm:
```bash
npm install @openpanel/web
```
See the [Web SDK docs](/docs/sdks/web) or [Script tag docs](/docs/sdks/script) for a full install guide.
## Enable session replay [#enable]
Session replay is **off by default**. Enable it by adding `sessionReplay: { enabled: true }` to your init config.
### Script tag
```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,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
},
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
The replay script (`op1-replay.js`) is fetched automatically alongside the main script. Because it loads asynchronously, it doesn't affect page load time or the size of your main analytics bundle.
### NPM package
```ts title="op.ts"
import { OpenPanel } from '@openpanel/web';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
sessionReplay: {
enabled: true,
},
});
```
With the npm package, the replay module is a dynamic import resolved by your bundler. It is automatically code-split from your main bundle—if you don't enable replay, the module is never included.
### Next.js
For Next.js, enable replay in your `OpenPanelComponent`:
```tsx title="app/layout.tsx"
import { OpenPanelComponent } from '@openpanel/nextjs';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<OpenPanelComponent
clientId="YOUR_CLIENT_ID"
trackScreenViews={true}
sessionReplay={{
enabled: true,
}}
/>
{children}
</body>
</html>
);
}
```
## Configure privacy controls [#privacy]
Session replay captures real user behavior, so it's important to control what gets recorded. OpenPanel gives you several layers of control.
### Input masking (enabled by default)
All form input values are masked by default. The recorder sees that a user typed something, but not what they typed. You never need to add special attributes to password or credit card fields—they're masked automatically.
If you need to disable masking for a specific use case:
```ts
sessionReplay: {
enabled: true,
maskAllInputs: false,
}
```
### Block sensitive elements
Elements with `data-openpanel-replay-block` are replaced with a grey placeholder in the replay. The element and all its children are completely excluded from recording.
```html
<!-- This section will appear as a placeholder in replays -->
<div data-openpanel-replay-block>
<img src="user-avatar.jpg" alt="Profile photo" />
<p>Private user content</p>
</div>
```
You can also configure a CSS selector or class to block without adding data attributes to every element:
```ts
sessionReplay: {
enabled: true,
blockSelector: '.payment-form, .user-profile-card',
blockClass: 'no-replay',
}
```
### Mask specific text
To mask text within an element without blocking its layout from the replay, use `data-openpanel-replay-mask`:
```html
<p>
Account balance:
<span data-openpanel-replay-mask>$1,234.56</span>
</p>
```
The span's text appears as `***` in the replay while the surrounding layout remains visible.
Configure a custom selector to avoid adding attributes to every element:
```ts
sessionReplay: {
enabled: true,
maskTextSelector: '.balance, .account-number, [data-pii]',
}
```
### Ignore interactions
Use `ignoreSelector` to prevent interactions with specific elements from being captured. The element is still visible in the replay, but clicks and input events on it are not recorded.
```ts
sessionReplay: {
enabled: true,
ignoreSelector: '.internal-debug-toolbar',
}
```
## View replays in the dashboard [#view]
Navigate to your [OpenPanel dashboard](https://dashboard.openpanel.dev) and open the Sessions view. Any recorded session will show a replay button. Click it to play back the session from the beginning.
The replay timeline shows all events alongside the recording, so you can jump directly to a click, form submission, or page navigation.
Replays are also accessible from user profiles. Open any user's profile, find a session in their history, and click through to the replay.
## Performance considerations
The replay recorder buffers events locally and sends them to OpenPanel in chunks every 10 seconds (configurable via `flushIntervalMs`). On tab close or page hide, any remaining buffered events are flushed immediately.
The default chunk size limits are:
- **200 events per chunk** (`maxEventsPerChunk`)
- **1 MB per payload** (`maxPayloadBytes`)
These defaults work well for most sites. If you have pages with heavy DOM activity, you can lower `maxEventsPerChunk` to send smaller, more frequent chunks:
```ts
sessionReplay: {
enabled: true,
flushIntervalMs: 5000,
maxEventsPerChunk: 100,
}
```
## Next steps
- Read the [session replay docs](/docs/session-replay) for a full option reference
- Learn about [session tracking](/features/session-tracking) to understand what session data is available without replay
- See how [funnels](/features/funnels) and session replay work together to diagnose drop-offs
<Faqs>
<FaqItem question="Does session replay affect my page load speed?">
No. The replay module (`op1-replay.js`) loads as a separate async script after the page and the main analytics script. It does not block rendering or inflate your main bundle.
</FaqItem>
<FaqItem question="Is session replay enabled for all users?">
Yes, when enabled, all sessions are recorded by default. You can use the `sampleRate` option to record only a percentage of sessions if needed.
</FaqItem>
<FaqItem question="Are passwords and credit card numbers recorded?">
No. All input field values are masked by default (`maskAllInputs: true`). The recorder captures that a user typed something, but not the actual characters. Disable this only with a specific reason.
</FaqItem>
<FaqItem question="How long are replays kept?">
Replays are retained for 30 days. There is no limit on the number of sessions recorded.
</FaqItem>
<FaqItem question="Can I use session replay with self-hosted OpenPanel?">
Yes. The replay script is served from your self-hosted instance automatically. You can also use the `scriptUrl` option to load it from a custom CDN.
</FaqItem>
</Faqs>

View File

@@ -7,7 +7,7 @@ description: Learn about OpenPanel, the open-source web and product analytics pl
**OpenPanel** is an open-source web and product analytics platform - a modern alternative to Mixpanel, Google Analytics, and Plausible. We're NOT a server control panel or hosting panel like other software that shares our name.
If you were looking for a server administration panel (like cPanel or Plesk), you might be looking for [OpenPanel](https://openpanel.com) - that's a different product for managing web servers. **OpenPanel.dev** is all about analytics.
If you were looking for a server administration panel (like cPanel or Plesk), you might be looking for [OpenPanel](https://openpanel.dev) - that's a different product for managing web servers. **OpenPanel.dev** is all about analytics.
## Introduction

View File

@@ -0,0 +1,132 @@
---
title: Data Processing Agreement
description: OpenPanel's Data Processing Agreement (DPA) under Art. 28 GDPR for cloud customers who use OpenPanel to collect analytics on their websites and applications.
---
_Last updated: March 3, 2026_
This Data Processing Agreement ("DPA") is incorporated into and forms part of the OpenPanel Terms of Service between OpenPanel AB ("OpenPanel", "we", "us") and the customer ("Controller", "you"). It applies where OpenPanel processes personal data on your behalf as part of the OpenPanel Cloud service.
## 1. Definitions
- **GDPR** means Regulation (EU) 2016/679 of the European Parliament and of the Council.
- **Controller** means you, the customer, who determines the purposes and means of processing.
- **Processor** means OpenPanel, who processes data on your behalf.
- **Personal Data**, **Processing**, **Data Subject**, and **Supervisory Authority** have the meanings given in the GDPR.
- **Sub-processor** means any third party engaged by OpenPanel to process Personal Data in connection with the service.
## 2. Our approach to privacy
OpenPanel is built to minimize personal data collection by design. We do not use cookies for analytics tracking. We do not store IP addresses. Instead, we generate a daily-rotating anonymous identifier using a one-way hash of the visitor's IP address, user agent, and project ID combined with a salt that is replaced every 24 hours. The raw IP address is discarded immediately and the identifier becomes irreversible once the salt is rotated.
The data we store per event is:
- Page URL and referrer
- Browser name and version
- Operating system name and version
- Device type, brand, and model
- City, country, and region (derived from IP at the time of the request; IP is then discarded)
- Custom event properties you choose to send
No persistent identifiers, no cookies, no cross-site tracking.
Because of this approach, the analytics data OpenPanel collects in standard website tracking mode does not constitute personal data under GDPR Art. 4(1). However, we provide this DPA for customers who require it for their own compliance documentation and records of processing activities.
**Session replay (optional feature)**
OpenPanel optionally supports session replay, which must be explicitly enabled by the Controller. When enabled, session replay records DOM snapshots and user interactions (mouse movements, clicks, scrolls) on the Controller's website using rrweb. This data is stored against the session identifier and may incidentally capture personal data visible in the page (for example, a logged-in user's name displayed in the UI). All text content and form inputs are masked by default. The Controller is responsible for ensuring their use of session replay complies with applicable privacy law, including providing appropriate notice to end users. Additional masking options are available via the SDK configuration.
## 3. Scope and roles
OpenPanel acts as a **Processor** when processing data on behalf of the Controller. You act as the **Controller** for the analytics data collected from visitors to your websites and applications.
## 4. Processor obligations
OpenPanel commits to the following:
- Process Personal Data only on your documented instructions and for no other purpose.
- Ensure that all personnel with access to Personal Data are bound by appropriate confidentiality obligations.
- Implement and maintain technical and organizational measures in accordance with Section 7 of this DPA.
- Not engage a Sub-processor without your prior general or specific written authorization and flow down equivalent data protection obligations to any Sub-processor.
- Assist you, where reasonably possible, in responding to Data Subject requests to exercise their rights under GDPR.
- Notify you without undue delay (and no later than 48 hours) upon becoming aware of a Personal Data breach.
- Make available all information necessary to demonstrate compliance with this DPA and cooperate with audits conducted by you or your designated auditor, subject to reasonable notice and confidentiality obligations.
- At your choice, delete or return all Personal Data upon termination of the service.
## 5. Your obligations as Controller
You confirm that:
- You have a lawful basis for the processing described in this DPA.
- You have provided appropriate privacy notices to your end users.
- You are responsible for the accuracy and lawfulness of the data you instruct OpenPanel to process.
## 6. Sub-processors
OpenPanel uses the following sub-processors to deliver the service. All sub-processors are located within the European Economic Area or provide adequate safeguards under GDPR Chapter V.
| Sub-processor | Purpose | Location |
|---|---|---|
| Hetzner Online GmbH | Cloud infrastructure and data storage | Germany (EU) |
| Cloudflare R2 | Backup storage | EU |
We will inform you of any intended changes to this list (additions or replacements) with reasonable notice, giving you the opportunity to object.
## 7. Technical and organizational measures
OpenPanel implements the following measures under GDPR Art. 32:
**Data minimization and anonymization**
- IP addresses are never stored. They are used only to derive geolocation and generate an anonymous daily identifier, then discarded.
- Daily-rotating cryptographic salts ensure visitor identifiers cannot be reversed or linked to individuals after 24 hours.
- No cookies or persistent cross-device identifiers are used.
**Access control**
- Dashboard access is protected by authentication and role-based access control.
- Production systems are accessible only to authorized personnel.
**Encryption and transport security**
- All data is transmitted over HTTPS (TLS).
**Infrastructure and availability**
- All data is hosted on Hetzner servers located in Germany within the EU.
- Regular backups are performed.
- No data leaves the EEA in the course of normal operations.
**Incident response**
- We maintain procedures for detecting, reporting, and investigating Personal Data breaches.
- In the event of a breach affecting your data, we will notify you within 48 hours of becoming aware.
**Open source**
- The OpenPanel codebase is publicly available on GitHub, allowing independent review of our data handling practices.
## 8. International data transfers
OpenPanel stores and processes all analytics data on Hetzner infrastructure located in Germany. No Personal Data is transferred to countries outside the EEA in the course of delivering the service.
## 9. Data retention and deletion
**Analytics events** are retained for as long as your account is active. We do not currently enforce a maximum retention period on analytics event data. If we introduce a retention limit in the future, we will notify all customers in advance.
**Session replays** are retained for 30 days and then permanently deleted.
You can delete individual projects, all associated data, or your entire account at any time from within the dashboard. Upon account termination we will delete your data within 30 days unless we are required by law to retain it longer.
## 10. Governing law
This DPA is governed by the laws of Sweden and is interpreted in accordance with the GDPR.
## 11. How to execute this DPA
Using OpenPanel Cloud constitutes acceptance of this DPA as part of our Terms of Service.
If your organization requires a signed copy for your records of processing activities, you can download a pre-signed version below. Fill in your company details and countersign — no need to send it back to us.
[Download pre-signed DPA](/dpa/download)
## Contact
For questions about this DPA or data protection at OpenPanel:
- Email: [hello@openpanel.dev](mailto:hello@openpanel.dev)
- Company: OpenPanel AB, Sankt Eriksgatan 100, 113 31 Stockholm, Sweden

View File

@@ -3,6 +3,8 @@ title: Privacy Policy
description: Our privacy policy outlines how we handle your data, including usage information and cookies, to provide and improve our services.
---
_Last updated: March 3, 2026_
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.
We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.
@@ -23,7 +25,7 @@ For the purposes of this Privacy Policy:
- **Application** refers to Openpanel, the software program provided by the Company.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to OpenPanel AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Cookies** are small files that are placed on Your computer, mobile device or any other device by a website, containing the details of Your browsing history on that website among its many uses.
@@ -43,65 +45,44 @@ For the purposes of this Privacy Policy:
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
## What we do not collect
OpenPanel is built around the principle of collecting as little data as possible. When you use OpenPanel to track visitors on your websites and applications, the following is true:
- **No IP addresses are stored.** IP addresses are used transiently to derive city-level geolocation and to generate an anonymous visitor identifier. The raw IP address is discarded immediately after.
- **No cookies are used for analytics tracking.** We do not set any tracking cookies on your visitors' browsers.
- **No persistent cross-device identifiers.** Visitor identifiers are generated using a daily-rotating cryptographic hash of the IP address, user agent, and project ID. The hash cannot be reversed and becomes meaningless after 24 hours.
- **No behavioral profiling.** We do not build profiles of individual users or track visitors across different websites.
- **No data sold to third parties.** We will never sell, share, or transfer your data for advertising or any other commercial purpose.
## Collecting and Using Your Personal Data
### Types of Data Collected
#### Personal Data
#### Analytics data (visitor data on your websites)
While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:
When the OpenPanel tracking script is installed on a website, the following aggregated, anonymized data is collected per event:
- Email address
- First name and last name
- Usage Data
- Page URL and referrer
- Browser name and version
- Operating system name and version
- Device type, brand, and model
- City, country, and region (derived from IP at request time; IP then discarded)
- Custom event properties the website owner chooses to send
#### Usage Data
No IP addresses, no cookies, no names, no email addresses, and no persistent identifiers are stored.
Usage Data is collected automatically when using the Service.
#### Account data (OpenPanel dashboard users)
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
When you create an OpenPanel account, we collect:
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.
- Email address (required for login and transactional notifications)
- Name (optional, used for display purposes)
- Billing information (processed by our payment provider; we do not store full card details)
We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.
#### Dashboard session
#### Tracking Technologies and Cookies
We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:
- **Cookies or Browser Cookies.** A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse Cookies, our Service may use Cookies.
- **Web Beacons.** Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser. You can learn more about cookies [here](https://www.termsfeed.com/blog/cookies/#What_Are_Cookies).
We use both Session and Persistent Cookies for the purposes set out below:
- **Necessary / Essential Cookies**
Type: Session Cookies
Administered by: Us
Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.
- **Cookies Policy / Notice Acceptance Cookies**
Type: Persistent Cookies
Administered by: Us
Purpose: These Cookies identify if users have accepted the use of cookies on the Website.
- **Functionality Cookies**
Type: Persistent Cookies
Administered by: Us
Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.
For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.
We use a single server-side session cookie to keep you logged in to the OpenPanel dashboard. This cookie is strictly necessary for authentication and is not used for tracking or analytics purposes. It is deleted when you log out or your session expires.
### Use of Your Personal Data
@@ -143,13 +124,11 @@ The Company will retain Your Personal Data only for as long as is necessary for
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
### Transfer of Your Personal Data
### Data storage and transfers
Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.
All analytics data is stored on Hetzner infrastructure located in Germany. All backups are stored on Cloudflare R2 within the EU. No analytics data is transferred outside the European Economic Area.
Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.
The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.
Account data (email, name, billing) is processed within the EU. Our payment processor and transactional email provider operate under EU data protection standards.
### Delete Your Personal Data
@@ -197,6 +176,10 @@ Our Service may contain links to other websites that are not operated by Us. If
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
## Data Processing Agreement
If you use OpenPanel Cloud to collect analytics on behalf of your own users, OpenPanel acts as a data processor and you act as the data controller. Our Data Processing Agreement (DPA) governs this relationship and forms part of our Terms of Service. You can read and download a signed copy at [openpanel.dev/dpa](/dpa).
## Changes to this Privacy Policy
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.

View File

@@ -3,6 +3,8 @@ title: Terms of Service
description: Legal terms and conditions governing the use of Openpanel's services and website.
---
_Last updated: March 3, 2026_
Please read these terms and conditions carefully before using Our Service.
## Interpretation and Definitions
@@ -25,7 +27,7 @@ For the purposes of these Terms and Conditions:
- **Country** refers to: Sweden
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to OpenPanel AB, Sankt Eriksgatan 100, 113 31, Stockholm.
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
@@ -55,6 +57,16 @@ You represent that you are over the age of 18. The Company does not permit those
Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.
## Data Processing Agreement
Our Data Processing Agreement (DPA) under the European General Data Protection Regulation (GDPR) forms part of these Terms of Service. By using OpenPanel Cloud, you agree to the terms of the DPA. A copy is available at [openpanel.dev/dpa](/dpa).
## Your Data
You retain full ownership of all data you submit to the Service, including analytics data collected from your websites and applications. The Company claims no intellectual property rights over your data.
We will never sell, share, or transfer your data to third parties for advertising, marketing, or any commercial purpose. Your data is used solely to provide and improve the Service for you.
## Subscriptions
### Subscription period
@@ -157,14 +169,6 @@ If You have any concern or dispute about the Service, You agree to first try to
If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which You are resident.
## United States Federal Government End Use Provisions
If You are a U.S. federal government end user, our Service is a "Commercial Item" as that term is defined at 48 C.F.R. §2.101.
## United States Legal Compliance
You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.
## Severability and Waiver
### Severability

View File

@@ -16,11 +16,11 @@
},
"dependencies": {
"@nivo/funnel": "^0.99.0",
"@number-flow/react": "0.5.10",
"@opennextjs/cloudflare": "^1.16.5",
"@number-flow/react": "0.6.0",
"@opennextjs/cloudflare": "^1.18.0",
"@openpanel/common": "workspace:*",
"@openpanel/geo": "workspace:*",
"@openpanel/nextjs": "^1.2.0",
"@openpanel/nextjs": "^1.4.0",
"@openpanel/payments": "workspace:^",
"@openpanel/sdk-info": "workspace:^",
"@openstatus/react": "0.0.3",
@@ -28,37 +28,37 @@
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tooltip": "1.2.8",
"cheerio": "^1.0.0",
"cheerio": "^1.2.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"dotted-map": "2.2.3",
"framer-motion": "12.23.25",
"fumadocs-core": "16.2.2",
"fumadocs-mdx": "14.0.4",
"fumadocs-ui": "16.2.2",
"geist": "1.5.1",
"dotted-map": "3.1.0",
"framer-motion": "12.38.0",
"fumadocs-core": "16.7.7",
"fumadocs-mdx": "14.2.11",
"fumadocs-ui": "16.7.7",
"geist": "1.7.0",
"lucide-react": "^0.555.0",
"next": "16.0.7",
"next": "16.2.1",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"recharts": "^2.15.0",
"recharts": "^2.15.4",
"rehype-external-links": "3.0.0",
"tailwind-merge": "3.4.0",
"tailwind-merge": "3.5.0",
"tailwindcss-animate": "1.0.7",
"zod": "catalog:"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "4.1.17",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "4.2.2",
"typescript": "catalog:",
"wrangler": "^4.65.0"
"wrangler": "^4.78.0"
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,45 +1,38 @@
import {
BarChart3Icon,
ChevronRightIcon,
DollarSignIcon,
GlobeIcon,
} from 'lucide-react';
import { ChevronRightIcon } from 'lucide-react';
import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card';
import { NotificationsIllustration } from '@/components/illustrations/notifications';
import { ProductAnalyticsIllustration } from '@/components/illustrations/product-analytics';
import { RetentionIllustration } from '@/components/illustrations/retention';
import { SessionReplayIllustration } from '@/components/illustrations/session-replay';
import { WebAnalyticsIllustration } from '@/components/illustrations/web-analytics';
import { Section, SectionHeader } from '@/components/section';
const features = [
function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const mediumFeatures = [
{
title: 'Revenue tracking',
title: 'Retention',
description:
'Track revenue from your payments and get insights into your revenue sources.',
icon: DollarSignIcon,
link: {
href: '/features/revenue-tracking',
children: 'More about revenue',
},
'Know how many users come back after day 1, day 7, day 30. Identify which behaviors predict long-term retention.',
illustration: wrap(<RetentionIllustration />),
link: { href: '/features/retention', children: 'View retention' },
},
{
title: 'Profiles & Sessions',
title: 'Session Replay',
description:
'Track individual users and their complete journey across your platform.',
icon: GlobeIcon,
link: {
href: '/features/identify-users',
children: 'Identify your users',
},
'Watch real user sessions to see exactly what happened — clicks, scrolls, rage clicks. Privacy controls built in.',
illustration: wrap(<SessionReplayIllustration />),
link: { href: '/features/session-replay', children: 'See session replay' },
},
{
title: 'Event Tracking',
title: 'Notifications',
description:
'Capture every important interaction with flexible event tracking.',
icon: BarChart3Icon,
link: {
href: '/features/event-tracking',
children: 'All about tracking',
},
'Get notified when a funnel is completed. Stay on top of key moments in your product without watching dashboards all day.',
illustration: wrap(<NotificationsIllustration />),
link: { href: '/features/notifications', children: 'Set up notifications' },
},
];
@@ -48,37 +41,39 @@ export function AnalyticsInsights() {
<Section className="container">
<SectionHeader
className="mb-16"
description="Combine web and product analytics in one platform. Track visitors, events, revenue, and user journeys, all with privacy-first tracking."
description="From first page view to long-term retention — every touchpoint in one platform. No sampling, no data limits, no guesswork."
label="ANALYTICS & INSIGHTS"
title="See the full picture of your users and product performance"
title="Everything you need to understand your users"
/>
<div className="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard
className="px-0 **:data-content:px-6"
description="Understand your website performance with privacy-first analytics and clear, actionable insights."
description="Understand your website performance with privacy-first analytics. Track visitors, referrers, and page views without touching user cookies."
illustration={<WebAnalyticsIllustration />}
title="Web Analytics"
variant="large"
/>
<FeatureCard
className="px-0 **:data-content:px-6"
description="Turn raw data into clarity with real-time visualization of performance, behavior, and trends."
description="Go beyond page views. Track custom events, understand user flows, and explore exactly how people use your product."
illustration={<ProductAnalyticsIllustration />}
title="Product Analytics"
variant="large"
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => (
{mediumFeatures.map((feature) => (
<FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description}
icon={feature.icon}
illustration={feature.illustration}
key={feature.title}
link={feature.link}
title={feature.title}
/>
))}
</div>
<p className="mt-8 text-center">
<Link
className="inline-flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-foreground"

View File

@@ -15,23 +15,23 @@ import { CollaborationChart } from './collaboration-chart';
const features = [
{
title: 'Visualize your data',
title: 'Flexible data visualization',
description:
'See your data in a visual way. You can create advanced reports and more to understand',
'Build line charts, bar charts, sankey flows, and custom dashboards. Combine metrics from any event into a single view.',
icon: ChartBarIcon,
slug: 'data-visualization',
},
{
title: 'Share & Collaborate',
description:
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.',
'Invite unlimited team members with org-wide or project-level access. Share dashboards publicly or lock them behind a password.',
icon: LayoutDashboardIcon,
slug: 'share-and-collaborate',
},
{
title: 'Integrations',
title: 'Integrations & Webhooks',
description:
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.',
'Forward events to your own systems or third-party tools. Connect OpenPanel to Slack, your data warehouse, or any webhook endpoint.',
icon: WorkflowIcon,
slug: 'integrations',
},

View File

@@ -43,9 +43,9 @@ export function DataPrivacy() {
/>
<div className="mt-16 mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<FeatureCard
description="Privacy-first analytics without cookies, fingerprinting, or invasive tracking. Built for compliance and user trust."
description="GDPR compliant and privacy-friendly analytics without cookies or invasive tracking. Data is EU hosted, and a Data Processing Agreement (DPA) is available to sign."
illustration={<PrivacyIllustration />}
title="Privacy-first"
title="GDPR compliant"
variant="large"
/>
<FeatureCard

View File

@@ -34,9 +34,9 @@ const faqData = [
'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare). You can also read our comprehensive guide on the [best open source web analytics tools](/articles/open-source-web-analytics).',
},
{
question: 'How does OpenPanel compare to Mixpanel?',
question: 'Is OpenPanel a good Mixpanel alternative?',
answer:
"OpenPanel offers similar powerful product analytics features as Mixpanel, but with the added benefits of being open-source, more affordable, and including web analytics capabilities.\n\nYou get Mixpanel's power with Plausible's simplicity.",
"Yes — OpenPanel covers the core features most teams use in Mixpanel: event tracking, funnels, retention, cohorts, and user profiles. The key differences are pricing, privacy, and self-hosting.\n\nOpenPanel starts at $2.50/month and can be self-hosted for free, while Mixpanel is cloud-only and scales to hundreds or thousands per month. OpenPanel is also cookie-free by default with EU-only hosting, so no consent banners required — something Mixpanel can't offer.\n\nSee the full [OpenPanel vs Mixpanel comparison](/compare/mixpanel-alternative) for a side-by-side breakdown.",
},
{
question: 'How does OpenPanel compare to Plausible?',

View File

@@ -0,0 +1,68 @@
import { FeatureCard } from '@/components/feature-card';
import { ConversionsIllustration } from '@/components/illustrations/conversions';
import { GoogleSearchConsoleIllustration } from '@/components/illustrations/google-search-console';
import { RevenueIllustration } from '@/components/illustrations/revenue';
import { Section, SectionHeader } from '@/components/section';
function wrap(child: React.ReactNode) {
return <div className="h-48 overflow-hidden">{child}</div>;
}
const features = [
{
title: 'Revenue Tracking',
description:
'Connect payment events to track MRR and see which referrers drive the most revenue.',
illustration: wrap(<RevenueIllustration />),
link: {
href: '/features/revenue-tracking',
children: 'Track revenue',
},
},
{
title: 'Conversion Tracking',
description:
'Monitor conversion rates over time and break down by A/B variant, country, or device. Catch regressions before they cost you.',
illustration: wrap(<ConversionsIllustration />),
link: {
href: '/features/conversion',
children: 'Track conversions',
},
},
{
title: 'Google Search Console',
description:
'See which search queries bring organic traffic and how visitors convert after landing. Your SEO and product data, in one place.',
illustration: wrap(<GoogleSearchConsoleIllustration />),
link: {
href: '/features/integrations',
children: 'View integrations',
},
},
];
export function FeatureSpotlight() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
description="OpenPanel goes beyond page views. Track revenue, monitor conversions, and connect your SEO data — all without switching tools."
label="GROWTH TOOLS"
title="Built for teams who ship and measure"
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{features.map((feature) => (
<FeatureCard
className="px-0 pt-0 **:data-content:px-6"
description={feature.description}
illustration={feature.illustration}
key={feature.title}
link={feature.link}
title={feature.title}
/>
))}
</div>
</Section>
);
}

View File

@@ -5,14 +5,14 @@ import {
CalendarIcon,
CookieIcon,
CreditCardIcon,
DatabaseIcon,
GithubIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { Competition } from '@/components/competition';
import { EuFlag } from '@/components/eu-flag';
import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks';
import { Button } from '@/components/ui/button';
@@ -21,10 +21,10 @@ import { cn } from '@/lib/utils';
const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon },
{ text: 'No credit card required', icon: CreditCardIcon },
{ text: 'GDPR compliant', icon: ShieldCheckIcon },
{ text: 'EU hosted', icon: EuFlag },
{ text: 'Cookie-less tracking', icon: CookieIcon },
{ text: 'Open-source', icon: GithubIcon },
{ text: 'Your data, your rules', icon: DatabaseIcon },
{ text: 'Self-hostable', icon: ServerIcon },
];
const aspectRatio = 2946 / 1329;
@@ -90,7 +90,7 @@ export function Hero() {
TRUSTED BY 1,000+ PROJECTS
</div>
<h1 className="font-semibold text-4xl leading-[1.1] md:text-5xl">
OpenPanel - The open-source alternative to <Competition />
The open-source alternative to <Competition />
</h1>
<p className="text-lg text-muted-foreground">
An open-source web and product analytics platform that combines the

View File

@@ -0,0 +1,63 @@
import { BarChart2Icon, CoinsIcon, GithubIcon, ServerIcon } from 'lucide-react';
import Link from 'next/link';
import { FeatureCard } from '@/components/feature-card';
import { GetStartedButton } from '@/components/get-started-button';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
const reasons = [
{
icon: CoinsIcon,
title: 'Fraction of the cost',
description:
"Mixpanel's pricing scales to hundreds or thousands per month as your event volume grows. OpenPanel starts at $2.50/month — or self-host for free with no event limits.",
},
{
icon: BarChart2Icon,
title: 'The features you actually use',
description:
'Events, funnels, retention, cohorts, user profiles, custom dashboards, and A/B testing — all there. OpenPanel covers every core analytics workflow from Mixpanel without the learning curve.',
},
{
icon: ServerIcon,
title: 'Actually self-hostable',
description:
'Mixpanel is cloud-only. OpenPanel runs on your own infrastructure with a simple Docker setup. Full data ownership, zero vendor lock-in.',
},
{
icon: GithubIcon,
title: 'Open source & transparent',
description:
"Mixpanel is a black box. OpenPanel's code is public on GitHub — audit it, contribute to it, or fork it. No surprises, no hidden data processing.",
},
];
export function MixpanelAlternative() {
return (
<Section className="container">
<SectionHeader
description="OpenPanel covers the product analytics features teams actually use — events, funnels, retention, cohorts, and user profiles — without Mixpanel's pricing, privacy trade-offs, or vendor lock-in."
label="Mixpanel Alternative"
title="Why teams switch from Mixpanel to OpenPanel"
/>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2">
{reasons.map((reason) => (
<FeatureCard
description={reason.description}
icon={reason.icon}
key={reason.title}
title={reason.title}
/>
))}
</div>
<div className="row mt-8 gap-4">
<GetStartedButton />
<Button asChild className="px-6" size="lg" variant="outline">
<Link href="/compare/mixpanel-alternative">
OpenPanel vs Mixpanel
</Link>
</Button>
</div>
</Section>
);
}

View File

@@ -55,6 +55,9 @@ export function Pricing() {
<div className="col mt-8 w-full items-baseline md:mt-auto">
{selected ? (
<>
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
<div className="row items-end gap-3">
<NumberFlow
className="font-bold text-5xl"
@@ -67,9 +70,6 @@ export function Pricing() {
locales={'en-US'}
value={selected.price}
/>
<span className="mb-2 rounded-full bg-primary/10 px-2.5 py-0.5 font-medium text-primary text-xs">
30-day free trial
</span>
</div>
<div className="row w-full justify-between">
<span className="-mt-2 text-muted-foreground/80 text-sm">

View File

@@ -1,13 +1,11 @@
'use client';
import { QuoteIcon, StarIcon } from 'lucide-react';
import Image from 'next/image';
import Markdown from 'react-markdown';
import { FeatureCardBackground } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { QuoteIcon } from 'lucide-react';
import Image from 'next/image';
import { useState } from 'react';
import Markdown from 'react-markdown';
const images = [
{
@@ -65,55 +63,63 @@ const quotes: {
];
export function WhyOpenPanel() {
const [showMore, setShowMore] = useState(false);
return (
<Section className="container gap-16">
<SectionHeader label="Trusted by founders" title="Who uses OpenPanel?" />
<div className="col overflow-hidden">
<SectionLabel className="text-muted-foreground bg-background -mb-2 z-5 self-start pr-4">
<SectionLabel className="z-5 -mb-2 self-start bg-background pr-4 text-muted-foreground">
USED BY
</SectionLabel>
<div className="grid grid-cols-3 md:grid-cols-6 -mx-4 border-y py-4">
<div className="-mx-4 grid grid-cols-3 border-y py-4 md:grid-cols-6">
{images.map((image) => (
<div key={image.logo} className="px-4 border-r last:border-r-0 ">
<div className="border-r px-4 last:border-r-0" key={image.logo}>
<a
className={cn('group center-center relative aspect-square')}
href={image.url}
target="_blank"
rel="noopener noreferrer nofollow"
key={image.logo}
className={cn('relative group center-center aspect-square')}
rel="noopener noreferrer nofollow"
target="_blank"
title={image.name}
>
<FeatureCardBackground />
<Image
src={image.logo}
alt={image.name}
width={64}
height={64}
className={cn('size-16 object-contain dark:invert')}
height={64}
src={image.logo}
width={64}
/>
</a>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 -mx-4 border-y py-4">
{quotes.slice(0, showMore ? quotes.length : 2).map((quote) => (
<div className="-mx-4 grid grid-cols-1 border-y py-4 md:grid-cols-2">
{quotes.slice(0, 2).map((quote) => (
<figure
className="group px-4 py-4 md:odd:border-r"
key={quote.author}
className="px-4 py-4 md:odd:border-r group"
>
<QuoteIcon className="size-10 text-muted-foreground/50 stroke-1 mb-2 group-hover:text-foreground group-hover:rotate-6 transition-all" />
<blockquote className="text-xl prose">
<div className="row items-center justify-between">
<QuoteIcon className="mb-2 size-10 stroke-1 text-muted-foreground/50 transition-all group-hover:rotate-6 group-hover:text-foreground" />
<div className="row gap-1">
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
<StarIcon className="size-4 fill-yellow-500 stroke-0 text-yellow-500" />
</div>
</div>
<blockquote className="prose text-justify text-xl">
<Markdown>{quote.quote}</Markdown>
</blockquote>
<figcaption className="row justify-between text-muted-foreground text-sm mt-4">
<figcaption className="row mt-4 justify-between text-muted-foreground text-sm">
<span>{quote.author}</span>
{quote.site && (
<cite className="not-italic">
<a
href={quote.site}
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
{quote.site.replace('https://', '')}
</a>
@@ -123,14 +129,6 @@ export function WhyOpenPanel() {
</figure>
))}
</div>
<Button
onClick={() => setShowMore((p) => !p)}
type="button"
variant="outline"
className="self-end mt-4"
>
{showMore ? 'Show less' : 'View more reviews'}
</Button>
</div>
</Section>
);

View File

@@ -1,8 +1,10 @@
import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration';
import { FeatureSpotlight } from './_sections/feature-spotlight';
import { CtaBanner } from './_sections/cta-banner';
import { DataPrivacy } from './_sections/data-privacy';
import { Faq } from './_sections/faq';
import { MixpanelAlternative } from './_sections/mixpanel-alternative';
import { Hero } from './_sections/hero';
import { Pricing } from './_sections/pricing';
import { Sdks } from './_sections/sdks';
@@ -57,10 +59,12 @@ export default function HomePage() {
<Hero />
<WhyOpenPanel />
<AnalyticsInsights />
<FeatureSpotlight />
<Collaboration />
<Testimonials />
<Pricing />
<DataPrivacy />
<MixpanelAlternative />
<Sdks />
<Faq />
<CtaBanner />

View File

@@ -0,0 +1,498 @@
'use client';
import Image from 'next/image';
export default function DpaDownloadPage() {
return (
<div className="min-h-screen bg-white text-black">
{/* Print button - hidden when printing */}
<div className="sticky top-0 z-10 flex justify-end gap-3 border-gray-200 border-b bg-white px-8 py-3 print:hidden">
<button
className="rounded bg-black px-4 py-2 font-medium text-sm text-white hover:bg-gray-800"
onClick={() => window.print()}
type="button"
>
Download / Print PDF
</button>
</div>
<div className="mx-auto max-w-3xl px-8 py-12 print:py-0">
{/* Header */}
<div className="mb-10 border-gray-300 border-b pb-8">
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
OpenPanel AB
</p>
<h1 className="mb-2 font-bold text-3xl">Data Processing Agreement</h1>
<p className="text-gray-500 text-sm">
Version 1.0 &middot; Last updated: March 3, 2026
</p>
</div>
<p className="mb-8 text-gray-700 text-sm leading-relaxed">
This Data Processing Agreement ("DPA") is entered into between
OpenPanel AB ("OpenPanel", "Processor") and the customer identified in
the signature block below ("Controller"). It applies where OpenPanel
processes personal data on behalf of the Controller as part of the
OpenPanel Cloud service, and forms part of the OpenPanel Terms of
Service.
</p>
<Section number="1" title="Definitions">
<ul className="list-none space-y-2 text-gray-700 text-sm">
<li>
<strong>GDPR</strong> means Regulation (EU) 2016/679 of the
European Parliament and of the Council.
</li>
<li>
<strong>Controller</strong> means the customer, who determines the
purposes and means of processing.
</li>
<li>
<strong>Processor</strong> means OpenPanel, who processes data on
the Controller's behalf.
</li>
<li>
<strong>Personal Data</strong>, <strong>Processing</strong>,{' '}
<strong>Data Subject</strong>, and{' '}
<strong>Supervisory Authority</strong> have the meanings given in
the GDPR.
</li>
<li>
<strong>Sub-processor</strong> means any third party engaged by
OpenPanel to process Personal Data in connection with the service.
</li>
</ul>
</Section>
<Section number="2" title="Our approach to privacy">
<p className="mb-3 text-gray-700 text-sm leading-relaxed">
OpenPanel is built to minimize personal data collection by design.
We do not use cookies for analytics tracking. We do not store IP
addresses. Instead, we generate a daily-rotating anonymous
identifier using a one-way hash of the visitor's IP address, user
agent, and project ID combined with a salt that is replaced every 24
hours. The raw IP address is discarded immediately and the
identifier becomes irreversible once the salt is rotated.
</p>
<p className="mb-2 text-gray-700 text-sm">
The data we store per event is:
</p>
<ul className="mb-3 list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>Page URL and referrer</li>
<li>Browser name and version</li>
<li>Operating system name and version</li>
<li>Device type, brand, and model</li>
<li>
City, country, and region (derived from IP at the time of the
request; IP is then discarded)
</li>
<li>Custom event properties the Controller chooses to send</li>
</ul>
<p className="mb-3 text-gray-700 text-sm">
No persistent identifiers, no cookies, no cross-site tracking.
Because of this approach, the analytics data OpenPanel collects in
standard website tracking mode does not constitute personal data
under GDPR Art. 4(1). We provide this DPA for Controllers who
require it for their own compliance documentation and records of
processing activities.
</p>
<p className="mb-1 text-gray-700 text-sm font-semibold">
Session replay (optional feature)
</p>
<p className="text-gray-700 text-sm">
OpenPanel optionally supports session replay, which must be
explicitly enabled by the Controller. When enabled, session replay
records DOM snapshots and user interactions (mouse movements, clicks,
scrolls) using rrweb. All text content and form inputs are masked by
default. The Controller is responsible for ensuring their use of
session replay complies with applicable privacy law, including
providing appropriate notice to end users.
</p>
</Section>
<Section number="3" title="Scope and roles">
<p className="text-gray-700 text-sm leading-relaxed">
OpenPanel acts as a Processor when processing data on behalf of the
Controller. The Controller is responsible for the analytics data
collected from visitors to their websites and applications.
</p>
</Section>
<Section number="4" title="Processor obligations">
<p className="mb-2 text-gray-700 text-sm">
OpenPanel commits to the following:
</p>
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>
Process Personal Data only on the Controller's documented
instructions and for no other purpose.
</li>
<li>
Ensure that all personnel with access to Personal Data are bound
by appropriate confidentiality obligations.
</li>
<li>
Implement and maintain technical and organizational measures in
accordance with Section 7 of this DPA.
</li>
<li>
Not engage a Sub-processor without prior general or specific
written authorization and flow down equivalent data protection
obligations to any Sub-processor.
</li>
<li>
Assist the Controller, where reasonably possible, in responding to
Data Subject requests to exercise their rights under GDPR.
</li>
<li>
Notify the Controller without undue delay (and no later than 48
hours) upon becoming aware of a Personal Data breach.
</li>
<li>
Make available all information necessary to demonstrate compliance
with this DPA and cooperate with audits conducted by the
Controller or their designated auditor, subject to reasonable
notice and confidentiality obligations.
</li>
<li>
At the Controller's choice, delete or return all Personal Data
upon termination of the service.
</li>
</ul>
</Section>
<Section number="5" title="Controller obligations">
<p className="mb-2 text-gray-700 text-sm">
The Controller confirms that:
</p>
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>
They have a lawful basis for the processing described in this DPA.
</li>
<li>
They have provided appropriate privacy notices to their end users.
</li>
<li>
They are responsible for the accuracy and lawfulness of the data
they instruct OpenPanel to process.
</li>
</ul>
</Section>
<Section number="6" title="Sub-processors">
<p className="mb-3 text-gray-700 text-sm">
OpenPanel uses the following sub-processors to deliver the service:
</p>
<table className="mb-3 w-full border-collapse text-sm">
<thead>
<tr className="border border-gray-300 bg-gray-50">
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
Sub-processor
</th>
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
Purpose
</th>
<th className="border border-gray-300 px-3 py-2 text-left font-semibold">
Location
</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-300 px-3 py-2">
Hetzner Online GmbH
</td>
<td className="border border-gray-300 px-3 py-2">
Cloud infrastructure and data storage
</td>
<td className="border border-gray-300 px-3 py-2">
Germany (EU)
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2">
Cloudflare R2
</td>
<td className="border border-gray-300 px-3 py-2">
Backup storage
</td>
<td className="border border-gray-300 px-3 py-2">EU</td>
</tr>
</tbody>
</table>
<p className="text-gray-700 text-sm">
OpenPanel will inform the Controller of any intended changes to this
list with reasonable notice, giving the Controller the opportunity
to object.
</p>
</Section>
<Section number="7" title="Technical and organizational measures">
<div className="space-y-4 text-gray-700 text-sm">
<div>
<p className="mb-1 font-semibold">
Data minimization and anonymization
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
IP addresses are never stored. They are used only to derive
geolocation and generate an anonymous daily identifier, then
discarded.
</li>
<li>
Daily-rotating cryptographic salts ensure visitor identifiers
cannot be reversed or linked to individuals after 24 hours.
</li>
<li>
No cookies or persistent cross-device identifiers are used.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">Access control</p>
<ul className="list-disc space-y-1 pl-5">
<li>
Dashboard access is protected by authentication and role-based
access control.
</li>
<li>
Production systems are accessible only to authorized
personnel.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">
Encryption and transport security
</p>
<ul className="list-disc space-y-1 pl-5">
<li>All data is transmitted over HTTPS (TLS).</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">
Infrastructure and availability
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
All data is hosted on Hetzner servers located in Germany
within the EU.
</li>
<li>Regular backups are performed.</li>
<li>
No data leaves the EEA in the course of normal operations.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">Incident response</p>
<ul className="list-disc space-y-1 pl-5">
<li>
We maintain procedures for detecting, reporting, and
investigating Personal Data breaches.
</li>
<li>
In the event of a breach affecting the Controller's data, we
will notify them within 48 hours of becoming aware.
</li>
</ul>
</div>
<div>
<p className="mb-1 font-semibold">Open source</p>
<ul className="list-disc space-y-1 pl-5">
<li>
The OpenPanel codebase is publicly available on GitHub,
allowing independent review of our data handling practices.
</li>
</ul>
</div>
</div>
</Section>
<Section number="8" title="International data transfers">
<p className="text-gray-700 text-sm leading-relaxed">
OpenPanel stores and processes all analytics data on Hetzner
infrastructure located in Germany. No Personal Data is transferred
to countries outside the EEA in the course of delivering the
service.
</p>
</Section>
<Section number="9" title="Data retention and deletion">
<ul className="list-disc space-y-1 pl-5 text-gray-700 text-sm">
<li>
<strong>Analytics events</strong> are retained for as long as the
Controller's account is active. No maximum retention period is
currently enforced. If a retention limit is introduced in the
future, all customers will be notified in advance.
</li>
<li>
<strong>Session replays</strong> are retained for 30 days and then
permanently deleted.
</li>
<li>
The Controller can delete individual projects, all associated data,
or their entire account at any time from within the dashboard. Upon
account termination, OpenPanel will delete the Controller's data
within 30 days unless required by law to retain it longer.
</li>
</ul>
</Section>
<Section number="10" title="Governing law">
<p className="text-gray-700 text-sm leading-relaxed">
This DPA is governed by the laws of Sweden and is interpreted in
accordance with the GDPR.
</p>
</Section>
{/* Exhibit A */}
<div className="mb-8 border-black border-t-2 pt-8">
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
Annex
</p>
<h2 className="mb-4 font-bold text-xl">
Exhibit A: Description of Processing
</h2>
<table className="w-full border-collapse text-sm">
<tbody>
<Row
label="Nature of processing"
value="Collection and storage of anonymized website analytics events (page views, custom events, session data). Optionally: session replay recording of DOM snapshots and user interactions."
/>
<Row
label="Purpose of processing"
value="To provide the Controller with website and product analytics via the OpenPanel Cloud dashboard. Session replay (if enabled) is used to allow the Controller to review user sessions for UX and debugging purposes."
/>
<Row
label="Duration of processing"
value="Analytics events: retained for the duration of the active account (no current maximum). Session replays: 30 days, then permanently deleted. All data deleted within 30 days of account termination."
/>
<Row
label="Categories of data subjects"
value="Visitors to the Controller's websites and applications"
/>
<Row
label="Categories of personal data"
value="Anonymized session identifiers (non-reversible after 24 hours), page URLs, referrers, browser type and version, operating system, device type, city-level geolocation (country, region, city). No IP addresses, no cookies, no names, no email addresses. If session replay is enabled: DOM snapshots and interaction recordings, which may incidentally contain personal data visible on the Controller's pages. All text content and form inputs are masked by default."
/>
<Row
label="Special categories of data"
value="None intended. The Controller is responsible for ensuring no special category data is captured via session replay."
/>
<Row
label="Sub-processors"
value="Hetzner Online GmbH (Germany) cloud infrastructure; Cloudflare R2 (EU) backup storage"
/>
</tbody>
</table>
</div>
{/* Signatures */}
<div className="border-black border-t-2 pt-8">
<p className="mb-1 text-gray-500 text-xs uppercase tracking-widest">
Execution
</p>
<h2 className="mb-6 font-bold text-xl">Signatures</h2>
<div className="grid grid-cols-2 gap-12">
{/* Processor - pre-signed */}
<div>
<div className="col h-32 gap-2">
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
Processor
</p>
<p className="font-semibold text-sm">OpenPanel AB</p>
<p className="text-gray-500 text-xs">
Sankt Eriksgatan 100, 113 31 Stockholm, Sweden
</p>
</div>
<SignatureLine
label="Signature"
value={
<Image
alt="Carl-Gerhard Lindesvärd signature"
className="relative top-4 h-16 w-auto object-contain object-left"
height={64}
src="/signature.png"
width={200}
/>
}
/>
<SignatureLine label="Name" value="Carl-Gerhard Lindesvärd" />
<SignatureLine label="Title" value="Founder" />
<SignatureLine label="Date" value="March 3, 2026" />
</div>
{/* Controller - blank */}
<div>
<div className="flex flex-col h-32 gap-2">
<p className="font-semibold text-gray-500 text-xs uppercase tracking-widest">
Controller
</p>
</div>
<SignatureLine label="Company" value="" />
<SignatureLine label="Signature" value="" />
<SignatureLine label="Name" value="" />
<SignatureLine label="Title" value="" />
<SignatureLine label="Date" value="" />
</div>
</div>
</div>
<div className="mt-12 border-gray-200 border-t pt-6 text-center text-gray-400 text-xs print:mt-4">
OpenPanel AB &middot; hello@openpanel.dev &middot; openpanel.dev/dpa
</div>
</div>
</div>
);
}
function Section({
number,
title,
children,
}: {
number: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="mb-8">
<h2 className="mb-3 font-bold text-base">
{number}. {title}
</h2>
{children}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<tr className="border border-gray-300">
<td className="w-48 border border-gray-300 bg-gray-50 px-3 py-2 align-top font-semibold text-xs">
{label}
</td>
<td className="border border-gray-300 px-3 py-2 text-xs leading-relaxed">
{value}
</td>
</tr>
);
}
function SignatureLine({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="mb-3">
<p className="text-gray-500 text-xs">{label}</p>
<div className="mt-1 flex h-7 items-end border-gray-400 border-b font-mono">
{value}
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { MetadataRoute } from 'next';
import { getAllForSlugs } from '@/lib/for';
import { url } from '@/lib/layout.shared';
import {
articleSource,
@@ -14,6 +15,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const docs = await source.getPages();
const pages = await pageSource.getPages();
const guides = await guideSource.getPages();
const forSlugs = await getAllForSlugs();
return [
{
url: url('/'),
@@ -119,5 +121,17 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
{
url: url('/for'),
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
...forSlugs.map((slug) => ({
url: url(`/for/${slug}`),
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
];
}

View File

@@ -3,9 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = getPageMetadata({
url: '/tools/ip-lookup',
title: 'IP Lookup - Free IP Address Geolocation Tool',
title: 'Free IP Address Lookup — Geolocation, ISP & ASN',
description:
'Find your IP address and get detailed geolocation information including country, city, ISP, ASN, and coordinates. Free IP lookup tool with map preview.',
'Instantly look up any IP address. Get country, city, region, ISP, ASN, and coordinates in seconds. Free tool, no signup required, powered by MaxMind GeoLite2.',
});
export default function IPLookupLayout({

View File

@@ -0,0 +1,37 @@
function star(cx: number, cy: number, outerR: number, innerR: number) {
const pts: string[] = [];
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (i * Math.PI) / 5 - Math.PI / 2;
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
return pts.join(' ');
}
const STARS = Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 - 90) * (Math.PI / 180);
return {
x: 12 + 5 * Math.cos(angle),
y: 8 + 5 * Math.sin(angle),
};
});
export function EuFlag({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 16"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="#003399" height="16" rx="1.5" width="24" />
{STARS.map((s, i) => (
<polygon
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
fill="#FFCC00"
points={star(s.x, s.y, 1.1, 0.45)}
/>
))}
</svg>
);
}

View File

@@ -124,6 +124,7 @@ export async function Footer() {
<Link href="/sitemap.xml">Sitemap</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
<Link href="/dpa">DPA</Link>
<Link href="/cookies">Cookie Policy (just kidding)</Link>
</div>
</div>

View File

@@ -0,0 +1,71 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const variantA = [28, 31, 29, 34, 32, 36, 35, 38, 37, 40, 39, 42];
const variantB = [28, 30, 32, 35, 38, 37, 40, 42, 44, 43, 47, 50];
export function ConversionsIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* A/B variant cards */}
<div className="row gap-3">
<div className="col flex-1 gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Variant A
</span>
</div>
<span className="font-bold font-mono text-xl">28.4%</span>
<SimpleChart
height={24}
points={variantA}
strokeColor="var(--foreground)"
width={200}
/>
</div>
<div className="col flex-1 gap-1 rounded-xl border border-emerald-500/30 bg-card p-3 transition-all delay-75 duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-1.5">
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[9px] text-emerald-600 dark:text-emerald-400">
Variant B
</span>
</div>
<span className="font-bold font-mono text-xl text-emerald-500">
41.2%
</span>
<SimpleChart
height={24}
points={variantB}
strokeColor="rgb(34, 197, 94)"
width={200}
/>
</div>
</div>
{/* Breakdown label */}
<div className="col gap-1 rounded-xl border bg-card/60 px-3 py-2.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
Breakdown by experiment variant
</span>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-foreground/50"
style={{ width: '57%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">A: 57%</span>
</div>
<div className="row items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-muted">
<div
className="h-1 rounded-full bg-emerald-500"
style={{ width: '82%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">B: 82%</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
const queries = [
{
query: 'openpanel analytics',
clicks: 312,
impressions: '4.1k',
pos: 1.2,
},
{
query: 'open source mixpanel alternative',
clicks: 187,
impressions: '3.8k',
pos: 2.4,
},
{
query: 'web analytics without cookies',
clicks: 98,
impressions: '2.2k',
pos: 4.7,
},
];
export function GoogleSearchConsoleIllustration() {
return (
<div className="col h-full gap-2 px-4 pt-5 pb-3">
{/* Top stats */}
<div className="row mb-1 gap-2">
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Clicks
</span>
<span className="font-bold font-mono text-sm">740</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Impr.
</span>
<span className="font-bold font-mono text-sm">13k</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. CTR
</span>
<span className="font-bold font-mono text-sm">5.7%</span>
</div>
<div className="col flex-1 gap-0.5 rounded-lg border bg-card px-2.5 py-2">
<span className="text-[8px] text-muted-foreground uppercase tracking-wider">
Avg. Pos
</span>
<span className="font-bold font-mono text-sm">2.8</span>
</div>
</div>
{/* Query table */}
<div className="flex-1 overflow-hidden rounded-xl border border-border bg-card">
<div className="row border-border border-b px-3 py-1.5">
<span className="flex-1 text-[8px] text-muted-foreground uppercase tracking-wider">
Query
</span>
<span className="w-10 text-right text-[8px] text-muted-foreground uppercase tracking-wider">
Pos
</span>
</div>
{queries.map((q, i) => (
<div
className="row items-center border-border/50 border-b px-3 py-1.5 last:border-0"
key={q.query}
style={{ opacity: 1 - i * 0.18 }}
>
<span className="flex-1 truncate text-[9px]">{q.query}</span>
<span className="w-10 text-right font-mono text-[9px] text-muted-foreground">
{q.pos}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CheckCircleIcon } from 'lucide-react';
export function NotificationsIllustration() {
return (
<div className="col h-full justify-center gap-3 px-6 py-4">
{/* Funnel completion notification */}
<div className="col gap-2 rounded-xl border border-border bg-card p-4 shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
<div className="row items-center gap-2">
<CheckCircleIcon className="size-4 shrink-0 text-emerald-500" />
<span className="font-semibold text-xs">Funnel completed</span>
<span className="ml-auto text-[9px] text-muted-foreground">
just now
</span>
</div>
<p className="font-medium text-sm">Signup Flow 142 today</p>
<div className="row items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-emerald-500"
style={{ width: '71%' }}
/>
</div>
<span className="text-[9px] text-muted-foreground">
71% conversion
</span>
</div>
</div>
{/* Notification rule */}
<div className="col gap-1.5 px-3 opacity-80">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
Notification rule
</span>
<div className="row flex-wrap items-center gap-1.5">
<span className="text-[9px] text-muted-foreground">When</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
Signup Flow
</span>
<span className="text-[9px] text-muted-foreground">completes </span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">
#growth
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
const cohorts = [
{ label: 'Week 1', values: [100, 68, 45, 38, 31] },
{ label: 'Week 2', values: [100, 72, 51, 42, 35] },
{ label: 'Week 3', values: [100, 65, 48, 39, null] },
{ label: 'Week 4', values: [100, 70, null, null, null] },
];
const headers = ['Day 0', 'Day 1', 'Day 7', 'Day 14', 'Day 30'];
function cellStyle(v: number | null) {
if (v === null) {
return {
backgroundColor: 'transparent',
borderColor: 'var(--border)',
color: 'var(--muted-foreground)',
};
}
const opacity = 0.12 + (v / 100) * 0.7;
return {
backgroundColor: `rgba(34, 197, 94, ${opacity})`,
borderColor: `rgba(34, 197, 94, 0.3)`,
color: v > 55 ? 'rgba(0,0,0,0.75)' : 'var(--foreground)',
};
}
export function RetentionIllustration() {
return (
<div className="h-full px-4 pb-3 pt-5">
<div className="col h-full gap-1.5">
<div className="row gap-1">
<div className="w-12 shrink-0" />
{headers.map((h) => (
<div
key={h}
className="flex-1 text-center text-[9px] text-muted-foreground"
>
{h}
</div>
))}
</div>
{cohorts.map(({ label, values }) => (
<div key={label} className="row flex-1 gap-1">
<div className="flex w-12 shrink-0 items-center text-[9px] text-muted-foreground">
{label}
</div>
{values.map((v, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static data
key={i}
className="flex flex-1 items-center justify-center rounded border text-[9px] font-medium transition-all duration-300 group-hover:scale-[1.03]"
style={cellStyle(v)}
>
{v !== null ? `${v}%` : '—'}
</div>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
const revenuePoints = [28, 34, 31, 40, 37, 44, 41, 50, 47, 56, 59, 65];
const referrers = [
{ name: 'google.com', amount: '$3,840', pct: 46 },
{ name: 'twitter.com', amount: '$1,920', pct: 23 },
{ name: 'github.com', amount: '$1,260', pct: 15 },
{ name: 'direct', amount: '$1,400', pct: 16 },
];
export function RevenueIllustration() {
return (
<div className="h-full col gap-3 px-4 pb-3 pt-5">
{/* MRR stat + chart */}
<div className="row gap-3">
<div className="col gap-1 rounded-xl border bg-card p-3 transition-all duration-300 group-hover:-translate-y-0.5">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
MRR
</span>
<span className="font-bold font-mono text-xl text-emerald-500">
$8,420
</span>
<span className="text-[9px] text-emerald-500"> 12% this month</span>
</div>
<div className="col flex-1 gap-1 rounded-xl border bg-card px-3 py-2">
<span className="text-[9px] text-muted-foreground">MRR over time</span>
<SimpleChart
className="mt-1 flex-1"
height={36}
points={revenuePoints}
strokeColor="rgb(34, 197, 94)"
width={400}
/>
</div>
</div>
{/* Revenue by referrer */}
<div className="flex-1 overflow-hidden rounded-xl border bg-card">
<div className="row border-b border-border px-3 py-1.5">
<span className="flex-1 text-[8px] uppercase tracking-wider text-muted-foreground">
Referrer
</span>
<span className="text-[8px] uppercase tracking-wider text-muted-foreground">
Revenue
</span>
</div>
{referrers.map((r) => (
<div
className="row items-center gap-2 border-b border-border/50 px-3 py-1.5 last:border-0"
key={r.name}
>
<span className="text-[9px] text-muted-foreground flex-none w-20 truncate">
{r.name}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-1 rounded-full bg-emerald-500/70"
style={{ width: `${r.pct}%` }}
/>
</div>
<span className="font-mono text-[9px] text-emerald-500 flex-none">
{r.amount}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { PlayIcon } from 'lucide-react';
export function SessionReplayIllustration() {
return (
<div className="h-full px-6 pb-3 pt-4">
<div className="col h-full overflow-hidden rounded-xl border border-border bg-background shadow-lg transition-transform duration-300 group-hover:-translate-y-0.5">
{/* Browser chrome */}
<div className="row shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2">
<div className="h-2 w-2 rounded-full bg-red-400" />
<div className="h-2 w-2 rounded-full bg-yellow-400" />
<div className="h-2 w-2 rounded-full bg-green-400" />
<div className="mx-2 flex-1 rounded bg-background/80 px-2 py-0.5 text-[8px] text-muted-foreground">
app.example.com/pricing
</div>
</div>
{/* Page content */}
<div className="relative flex-1 overflow-hidden p-3">
<div className="mb-2 h-2 w-20 rounded-full bg-muted/60" />
<div className="mb-4 h-2 w-32 rounded-full bg-muted/40" />
<div className="row mb-3 gap-2">
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
<div className="h-10 flex-1 rounded-lg border border-border bg-muted/20" />
</div>
<div className="mb-2 h-2 w-28 rounded-full bg-muted/30" />
<div className="h-2 w-24 rounded-full bg-muted/20" />
{/* Click heatspot */}
<div
className="absolute"
style={{ left: '62%', top: '48%' }}
>
<div className="h-4 w-4 animate-pulse rounded-full border-2 border-blue-500/70 bg-blue-500/20" />
</div>
<div
className="absolute"
style={{ left: '25%', top: '32%' }}
>
<div className="h-2.5 w-2.5 rounded-full border border-blue-500/40 bg-blue-500/25" />
</div>
{/* Cursor trail */}
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ overflow: 'visible' }}
>
<path
d="M 18% 22% Q 42% 28% 62% 48%"
fill="none"
stroke="rgb(59 130 246 / 0.35)"
strokeDasharray="3 2"
strokeWidth="1"
/>
</svg>
{/* Cursor */}
<div
className="absolute"
style={{
left: 'calc(62% + 8px)',
top: 'calc(48% + 6px)',
}}
>
<svg fill="none" height="12" viewBox="0 0 10 12" width="10">
<path
d="M0 0L0 10L3 7L5 11L6.5 10.5L4.5 6.5L8 6L0 0Z"
fill="var(--foreground)"
/>
</svg>
</div>
</div>
{/* Playback bar */}
<div className="row shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2">
<PlayIcon className="size-3 shrink-0 text-muted-foreground" />
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-muted">
<div
className="absolute left-0 top-0 h-1 rounded-full bg-blue-500"
style={{ width: '42%' }}
/>
</div>
<span className="font-mono text-[8px] text-muted-foreground">
0:52 / 2:05
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,188 +1,165 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [
const VISITOR_DATA = [1840, 2100, 1950, 2400, 2250, 2650, 2980];
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const STATS = [
{ label: 'Visitors', value: 4128, formatted: null, change: 12, up: true },
{ label: 'Page views', value: 12438, formatted: '12.4k', change: 8, up: true },
{ label: 'Bounce rate', value: null, formatted: '42%', change: 3, up: false },
{ label: 'Avg. session', value: null, formatted: '3m 23s', change: 5, up: true },
];
const SOURCES = [
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
name: 'Google',
percentage: 49,
value: 2039,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
name: 'Instagram',
percentage: 23,
value: 920,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
name: 'Facebook',
percentage: 18,
value: 750,
name: 'google.com',
pct: 49,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter',
percentage: 10,
value: 412,
name: 'twitter.com',
pct: 21,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgithub.com',
name: 'github.com',
pct: 14,
},
];
const COUNTRIES = [
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
];
function AreaChart({ data }: { data: number[] }) {
const max = Math.max(...data);
const w = 400;
const h = 64;
const xStep = w / (data.length - 1);
const pts = data.map((v, i) => ({ x: i * xStep, y: h - (v / max) * h }));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
const area = `${line} L ${w},${h} L 0,${h} Z`;
const last = pts[pts.length - 1];
return (
<svg className="w-full" viewBox={`0 0 ${w} ${h + 4}`}>
<defs>
<linearGradient id="wa-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgb(59 130 246)" stopOpacity="0.25" />
<stop offset="100%" stopColor="rgb(59 130 246)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#wa-fill)" />
<path
d={line}
fill="none"
stroke="rgb(59 130 246)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<circle cx={last.x} cy={last.y} fill="rgb(59 130 246)" r="3" />
<circle
cx={last.x}
cy={last.y}
fill="none"
r="6"
stroke="rgb(59 130 246)"
strokeOpacity="0.3"
strokeWidth="1.5"
/>
</svg>
);
}
export function WebAnalyticsIllustration() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [liveVisitors, setLiveVisitors] = useState(47);
useEffect(() => {
const interval = setInterval(() => {
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
}, 3000);
return () => clearInterval(interval);
const values = [47, 51, 44, 53, 49, 56];
let i = 0;
const id = setInterval(() => {
i = (i + 1) % values.length;
setLiveVisitors(values[i]);
}, 2500);
return () => clearInterval(id);
}, []);
return (
<div className="px-12 group aspect-video">
<div className="relative h-full col">
<MetricCard
title="Session duration"
value="3m 23s"
change="3%"
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
/>
<MetricCard
title="Bounce rate"
value="46%"
change="3%"
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
/>
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
<BarCell
{...TRAFFIC_SOURCES[currentSourceIndex]}
className="group-hover:scale-105 transition-all duration-300"
/>
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
className="group-hover:scale-105 transition-all duration-300"
/>
</div>
</div>
</div>
);
}
function MetricCard({
title,
value,
change,
chartPoints,
color,
className,
}: {
title: string;
value: string;
change: string;
chartPoints: number[];
color: string;
className?: string;
}) {
return (
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
<div className="row items-end justify-between">
<div>
<div className="text-muted-foreground text-sm">{title}</div>
<div className="text-2xl font-semibold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium">
<ArrowUpIcon className="size-3" strokeWidth={3} />
<div>{change}</div>
</div>
</div>
<SimpleChart
width={400}
height={30}
points={chartPoints}
strokeColor={color}
className="mt-4"
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
className,
}: {
icon: string;
name: string;
percentage: number;
value: number;
className?: string;
}) {
return (
<div
className={cn(
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
className,
)}
>
<div
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
style={{
width: `${percentage}%`,
}}
/>
<div className="relative row justify-between ">
<div className="row gap-2 items-center font-medium text-sm">
{icon.startsWith('http') ? (
<Image
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
width={16}
height={16}
/>
) : (
<div className="text-2xl">{icon}</div>
)}
<AnimatePresence mode="popLayout">
<motion.div
key={name}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
{name}
</motion.div>
</AnimatePresence>
</div>
<div className="row gap-3 font-mono text-sm">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
<div className="aspect-video col gap-2.5 p-5">
{/* Header */}
<div className="row items-center justify-between">
<div className="row items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="text-[10px] font-medium text-muted-foreground">
<NumberFlow value={liveVisitors} /> online now
</span>
<NumberFlow value={value} locales={'en-US'} />
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] text-muted-foreground">
Last 7 days
</span>
</div>
{/* KPI tiles */}
<div className="grid grid-cols-4 gap-1.5">
{STATS.map((stat) => (
<div
className="col gap-0.5 rounded-lg border bg-card px-2 py-1.5"
key={stat.label}
>
<span className="text-[8px] text-muted-foreground">{stat.label}</span>
<span className="font-mono font-semibold text-xs leading-tight">
{stat.formatted ??
(stat.value !== null ? (
<NumberFlow locales="en-US" value={stat.value} />
) : null)}
</span>
<span
className={`text-[8px] ${stat.up ? 'text-emerald-500' : 'text-red-400'}`}
>
{stat.up ? '↑' : '↓'} {stat.change}%
</span>
</div>
))}
</div>
{/* Area chart */}
<div className="flex-1 col gap-1 overflow-hidden rounded-xl border bg-card px-3 pt-2 pb-1">
<span className="text-[8px] text-muted-foreground">Unique visitors</span>
<AreaChart data={VISITOR_DATA} />
<div className="row justify-between px-0.5">
{DAYS.map((d) => (
<span className="text-[7px] text-muted-foreground" key={d}>
{d}
</span>
))}
</div>
</div>
{/* Traffic sources */}
<div className="row gap-1.5">
{SOURCES.map((src) => (
<div
className="row flex-1 items-center gap-1.5 overflow-hidden rounded-lg border bg-card px-2 py-1.5"
key={src.name}
>
<Image
alt={src.name}
className="rounded-[2px] object-contain"
height={10}
src={src.icon}
width={10}
/>
<span className="flex-1 truncate text-[9px]">{src.name}</span>
<span className="font-mono text-[9px] text-muted-foreground">
{src.pct}%
</span>
</div>
))}
</div>
</div>
);

View File

@@ -1,10 +1,13 @@
import type React from 'react';
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
type PerkIcon = LucideIcon | React.ComponentType<{ className?: string }>;
export function Perks({
perks,
className,
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
}: { perks: { text: string; icon: PerkIcon }[]; className?: string }) {
return (
<ul className={cn('grid grid-cols-2 gap-2', className)}>
{perks.map((perk) => (

View File

@@ -3,4 +3,15 @@
- `trackScreenViews` - If true, the library will automatically track screen views (default: false)
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)
- `sessionReplay` - Session replay configuration object (default: disabled). See [session replay docs](/docs/session-replay) for full options.
- `enabled` - Enable session replay recording (default: false)
- `maskAllInputs` - Mask all input field values (default: true)
- `maskTextSelector` - CSS selector for text elements to mask (default: `[data-openpanel-replay-mask]`)
- `blockSelector` - CSS selector for elements to replace with a placeholder (default: `[data-openpanel-replay-block]`)
- `blockClass` - Class name that blocks elements from being recorded
- `ignoreSelector` - CSS selector for elements excluded from interaction tracking
- `flushIntervalMs` - How often (ms) recorded events are sent to the server (default: 10000)
- `maxEventsPerChunk` - Maximum events per payload chunk (default: 200)
- `maxPayloadBytes` - Maximum payload size in bytes (default: 1048576)
- `scriptUrl` - Custom URL for the replay script (script-tag builds only)

View File

@@ -26,11 +26,11 @@ export function baseOptions(): BaseLayoutProps {
export const authors = [
{
name: 'OpenPanel Team',
url: 'https://openpanel.com',
url: 'https://openpanel.dev',
},
{
name: 'Carl-Gerhard Lindesvärd',
url: 'https://openpanel.com',
url: 'https://openpanel.dev',
image: '/twitter-carl.jpg',
},
];

View File

@@ -1,4 +1,4 @@
ARG NODE_VERSION=22.20.0
ARG NODE_VERSION=22.22.2
FROM node:${NODE_VERSION}-slim AS base

View File

@@ -6,7 +6,7 @@
"dev": "pnpm with-env vite dev --port 3000",
"start_deprecated": "pnpm with-env node .output/server/index.mjs",
"preview": "vite preview",
"deploy": "npx wrangler deploy",
"deploy": "pnpm build && npx wrangler deploy",
"cf-typegen": "wrangler types",
"build": "pnpm with-env vite build",
"serve": "vite preview",
@@ -17,21 +17,21 @@
"with-env": "dotenv -e ../../.env -c --"
},
"dependencies": {
"@ai-sdk/react": "^1.2.5",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-javascript": "^6.2.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.0",
"@ai-sdk/react": "^1.2.12",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.35.0",
"@codemirror/view": "^6.40.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^9.6.0",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@faker-js/faker": "^9.9.0",
"@hookform/resolvers": "^3.10.0",
"@hyperdx/node-opentelemetry": "^0.10.3",
"@nivo/sankey": "^0.99.0",
"@number-flow/react": "0.5.10",
"@number-flow/react": "0.6.0",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/importer": "workspace:^",
@@ -40,100 +40,100 @@
"@openpanel/payments": "workspace:*",
"@openpanel/sdk-info": "workspace:^",
"@openpanel/validation": "workspace:^",
"@openpanel/web": "^1.0.1",
"@openpanel/web": "^1.3.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/tanstackstart-react": "^9.12.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/tanstackstart-react": "^10.46.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.6",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/nitro-v2-vite-plugin": "^1.133.19",
"@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.132.47",
"@tanstack/react-router-devtools": "^1.132.51",
"@tanstack/react-router-ssr-query": "^1.132.47",
"@tanstack/react-start": "^1.132.56",
"@tanstack/react-store": "^0.8.0",
"@tanstack/nitro-v2-vite-plugin": "^1.154.9",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
"@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-router-ssr-query": "^1.166.10",
"@tanstack/react-start": "^1.167.16",
"@tanstack/react-store": "^0.9.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/router-plugin": "^1.132.56",
"@tanstack/store": "^0.8.0",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@trpc/tanstack-react-query": "^11.6.0",
"@tanstack/react-virtual": "^3.13.23",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/store": "^0.9.3",
"@trpc/client": "^11.16.0",
"@trpc/react-query": "^11.16.0",
"@trpc/server": "^11.16.0",
"@trpc/tanstack-react-query": "^11.16.0",
"@types/d3": "^7.4.3",
"ai": "^4.2.10",
"ai": "^4.3.19",
"bind-event-listener": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"codemirror": "^6.0.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"debounce": "^2.2.0",
"codemirror": "^6.0.2",
"d3": "^7.9.0",
"date-fns": "^3.6.0",
"debounce": "^3.0.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "8.0.0-rc22",
"flag-icons": "^7.1.0",
"framer-motion": "^11.0.28",
"hamburger-react": "^2.5.0",
"input-otp": "^1.2.4",
"javascript-time-ago": "^2.5.9",
"katex": "^0.16.21",
"embla-carousel-react": "8.6.0",
"flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"hamburger-react": "^2.5.2",
"input-otp": "^1.4.2",
"javascript-time-ago": "^2.6.4",
"katex": "^0.16.44",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lottie-react": "^2.4.1",
"lucide-react": "^0.476.0",
"mitt": "^3.0.1",
"nuqs": "^2.5.2",
"nuqs": "^2.8.9",
"prisma-error-enum": "^0.1.3",
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"pushmodal": "^1.0.5",
"ramda": "^0.32.0",
"random-animal-name": "^0.1.1",
"rc-virtual-list": "^3.14.5",
"rc-virtual-list": "^3.19.2",
"react": "catalog:",
"react-animate-height": "^3.2.3",
"react-animated-numbers": "^1.1.1",
"react-day-picker": "^9.9.0",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.50.1",
"react-in-viewport": "1.0.0-beta.8",
"react-grid-layout": "^1.5.3",
"react-hook-form": "^7.72.0",
"react-in-viewport": "1.0.0-beta.9",
"react-markdown": "^10.1.0",
"react-redux": "^8.1.3",
"react-resizable": "^3.0.5",
"react-responsive": "^9.0.2",
"react-resizable": "^3.1.3",
"react-responsive": "^10.0.1",
"react-simple-maps": "3.0.0",
"react-svg-worldmap": "2.0.0-alpha.16",
"react-syntax-highlighter": "^15.5.0",
"react-use-websocket": "^4.7.0",
"react-virtualized-auto-sizer": "^1.0.22",
"react-svg-worldmap": "2.0.1",
"react-syntax-highlighter": "^16.1.1",
"react-use-websocket": "^4.13.0",
"react-virtualized-auto-sizer": "^1.0.26",
"recharts": "^2.15.4",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
@@ -142,42 +142,42 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"rrweb-player": "2.0.0-alpha.20",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"short-unique-id": "^5.3.2",
"slugify": "^1.6.8",
"sonner": "^1.7.4",
"sqlstring": "^2.3.3",
"superjson": "^2.2.2",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"superjson": "^2.2.6",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.6",
"usehooks-ts": "^2.14.0",
"vite-tsconfig-paths": "^5.1.4",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^2.16.0",
"vite-tsconfig-paths": "^6.1.1",
"zod": "catalog:"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "1.20.3",
"@biomejs/biome": "2.4.10",
"@cloudflare/vite-plugin": "1.30.2",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@tanstack/devtools-event-client": "^0.4.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9",
"@types/ramda": "^0.31.0",
"@types/ramda": "^0.31.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-grid-layout": "^1.3.5",
"@types/react-simple-maps": "^3.0.4",
"@types/react-syntax-highlighter": "^15.5.11",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"@types/react-grid-layout": "^2.1.0",
"@types/react-simple-maps": "^3.0.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.7.0",
"jsdom": "^26.1.0",
"typescript": "catalog:",
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "4.59.1"
"vite": "^6.4.1",
"vitest": "^3.2.4",
"web-vitals": "^5.2.0",
"wrangler": "4.78.0"
}
}

View File

@@ -13,7 +13,7 @@ import { Button } from '../ui/button';
const validator = zSignInEmail;
type IForm = z.infer<typeof validator>;
export function SignInEmailForm() {
export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInEmail.mutationOptions({
@@ -54,9 +54,16 @@ export function SignInEmailForm() {
type="password"
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
/>
<Button type="submit" size="lg">
Sign in
</Button>
<div className="relative">
<Button type="submit" size="lg" className="w-full">
Sign in
</Button>
{isLastUsed && (
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
Used last time
</span>
)}
</div>
<button
type="button"
onClick={() =>

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