269 Commits

Author SHA1 Message Date
8ca774ad1c docs: trigger worker rebuild
Some checks failed
Build and Push API / build-api (push) Has been cancelled
Build and Push Dashboard / build-dashboard (push) Has been cancelled
Build and Push Worker / build-worker (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:17:06 +02:00
53c989462a Revert "fix(ci):consolidate dashboard build workflow changes"
This reverts commit 58414f1035.
2026-04-09 09:17:03 +02:00
58414f1035 fix(ci):consolidate dashboard build workflow changes
Some checks failed
Build and Push API / build-api (push) Successful in 5m56s
Build and Push Dashboard / build-dashboard (push) Successful in 14m37s
Build and Push Worker / build-worker (push) Failing after 4m50s
2026-04-08 16:53:40 +02:00
31fbe0a809 fix(ci):increase build limits preventing heap OOM
Some checks failed
Build and Push API / build-api (pull_request) Successful in 6m55s
Build and Push Worker / build-worker (pull_request) Has been cancelled
Build and Push Dashboard / build-dashboard (pull_request) Successful in 2h6m6s
Build and Push API / build-api (push) Successful in 8m40s
Build and Push Worker / build-worker (push) Successful in 7m23s
Build and Push Dashboard / build-dashboard (push) Failing after 1h53m54s
2026-04-01 11:27:08 +02:00
1b23fee108 fix(ci):overhaul the dash build
Some checks failed
Build and Push API / build-api (push) Successful in 8m11s
Build and Push Dashboard / build-dashboard (push) Failing after 37m52s
Build and Push Worker / build-worker (push) Successful in 8m25s
Build and Push API / build-api (pull_request) Successful in 7m13s
Build and Push Dashboard / build-dashboard (pull_request) Failing after 34m41s
Build and Push Worker / build-worker (pull_request) Successful in 6m52s
2026-04-01 09:40:25 +02:00
3043a9cdd1 fix(ci):deployments need pnpm in base image
Some checks failed
Build and Push API / build-api (push) Successful in 9m9s
Build and Push Dashboard / build-dashboard (push) Failing after 48m51s
Build and Push Worker / build-worker (push) Successful in 8m32s
Build and Push API / build-api (pull_request) Successful in 6m39s
Build and Push Dashboard / build-dashboard (pull_request) Failing after 48m22s
Build and Push Worker / build-worker (pull_request) Successful in 6m7s
2026-03-31 20:02:51 +02:00
655ea1f87e feat:add otel logging 2026-03-31 16:45:05 +02:00
fcb4cf5fb0 ci:add deployments
Some checks failed
Build and Push API / build-api (push) Failing after 44m22s
Build and Push Dashboard / build-dashboard (push) Has been cancelled
Build and Push Worker / build-worker (push) Has been cancelled
2026-03-31 15:54:58 +02:00
9b197abcfa chore:little fixes and formating and linting and patches 2026-03-31 15:50:54 +02:00
Carl-Gerhard Lindesvärd
a1ce71ffb6 fix:buffers
* wip

* remove active visitor counter in redis

* test

* fix profiel query

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

* wip

* wip

* wip

* wip

* add buffer

* wip

* wip

* fixes

* fix

* wip

* group validation

* fix group issues

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

* perf: optimize event buffer

* remove unused file

* fix

* wip

* wip: try groupmq 2

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

* wip

* wip

* wip

* final fixes

* comments

* fix
2026-02-26 14:09:53 +01:00
Carl-Gerhard Lindesvärd
38d9b65ec8 public: add more content 2026-02-25 22:27:04 +01:00
Carl-Gerhard Lindesvärd
f311146ade fix: all tests 2026-02-25 13:03:32 +01:00
Carl-Gerhard Lindesvärd
6dca57d7ce fix: session issues 2026-02-25 12:42:43 +01:00
Carl-Gerhard Lindesvärd
a4cb410d3e disabled ultracite for a minute 2026-02-25 11:13:12 +01:00
Carl-Gerhard Lindesvärd
c69ff7053a fix: validate revenue to not be negative 2026-02-18 18:46:51 +01:00
Carl-Gerhard Lindesvärd
ee27568824 feat: backfill profile id on events 2026-02-18 17:42:17 +01:00
Carl-Gerhard Lindesvärd
7e2d93db45 feat: add exclude event filters 2026-02-18 11:47:35 +01:00
Carl-Gerhard Lindesvärd
03c18b37ec feat: add weekly trends 2026-02-18 10:44:19 +01:00
Carl-Gerhard Lindesvärd
b81a2e0de6 fix: show all insights on overview 2026-02-18 10:44:03 +01:00
Carl-Gerhard Lindesvärd
d7e6e737c9 fix: improve overview filters 2026-02-18 10:43:55 +01:00
Carl-Gerhard Lindesvärd
1281cfa7b3 chore: add claude 2026-02-18 10:43:07 +01:00
Carl-Gerhard Lindesvärd
81b85e09b8 bump: nextjs 1.1.4 2026-02-17 13:51:46 +01:00
Carl-Gerhard Lindesvärd
5a0769c917 fix(sdk): use after interactive for nextjs #290 2026-02-17 13:51:46 +01:00
Carl-Gerhard Lindesvärd
fef4941e06 fix: remove faqpage 2026-02-17 13:51:46 +01:00
Kashish Sahu
cdc286b3fd fix(api): incorrect error message for invalid uuidv4 client ID (#291) 2026-02-17 13:39:30 +01:00
Carl-Gerhard Lindesvärd
fbb2606130 fix: demo 2026-02-17 11:06:25 +01:00
Carl-Gerhard Lindesvärd
bef3eb2e90 chore: use cloudflare worker instead of vercel 2026-02-17 11:06:19 +01:00
Carl-Gerhard Lindesvärd
ab59c12721 fix: pagination issue on events list 2026-02-17 09:20:26 +01:00
Carl-Gerhard Lindesvärd
e3faab7588 public: new page and copy improvements 2026-02-17 00:21:13 +01:00
Carl-Gerhard Lindesvärd
0ebe2768be public: update cookieless article 2026-02-16 22:31:51 +01:00
Carl-Gerhard Lindesvärd
b7eafb0f37 fix: improve sidebar buttons 2026-02-16 22:16:56 +01:00
Carl-Gerhard Lindesvärd
edadda2c45 public: update self-hosting articles 2026-02-16 15:45:14 +01:00
Carl-Gerhard Lindesvärd
b6b716f1a1 public: fix cors sign up button 2026-02-16 15:38:36 +01:00
Carl-Gerhard Lindesvärd
d54a33b5c2 public: add conditional sign in / sign up button in navbar 2026-02-16 15:20:10 +01:00
Carl-Gerhard Lindesvärd
4f4b4a8d88 public: seo work 2026-02-16 15:12:15 +01:00
Carl-Gerhard Lindesvärd
2f95c074c5 public: update about 2026-02-14 10:43:48 +00:00
Carl-Gerhard Lindesvärd
860223f22e chore: use ultacite 2026-02-14 10:42:38 +00:00
Carl-Gerhard Lindesvärd
6c55f7a759 public: broken faq markup 2026-02-13 16:25:48 +00:00
Carl-Gerhard Lindesvärd
5d802c40df public: update articles 2026-02-13 06:48:35 +00:00
Carl-Gerhard Lindesvärd
3fc45acbd2 public: update article 2026-02-12 21:56:28 +00:00
Carl-Gerhard Lindesvärd
22fa473706 public: update compare pages 2026-02-12 21:55:53 +00:00
Carl-Gerhard Lindesvärd
9d6087c32a public: update groupmq article 2026-02-12 21:48:04 +00:00
Carl-Gerhard Lindesvärd
1ccc862e9b public: add redirects 2026-02-12 21:47:09 +00:00
Carl-Gerhard Lindesvärd
b3afaf3d5f public: update mixpanel pricing 2026-02-12 21:46:55 +00:00
Carl-Gerhard Lindesvärd
68b12b36cc fix: broken links for onboarding 2026-02-12 20:49:06 +00:00
Carl-Gerhard Lindesvärd
2389b351e4 fix: memory issues when properties are to big 2026-02-12 20:35:21 +00:00
Carl-Gerhard Lindesvärd
f1f932c58b fix: funnel issues 2026-02-12 20:32:18 +00:00
Carl-Gerhard Lindesvärd
c8d78e31a1 docs: add overview for each compare page 2026-02-09 22:17:52 +00:00
Carl-Gerhard Lindesvärd
9f441fd9fa docs: add llms 2026-02-09 21:52:27 +00:00
Carl-Gerhard Lindesvärd
40a3178b57 fix: decrease profile flush to 10s 2026-02-09 12:05:17 +00:00
Carl-Gerhard Lindesvärd
e129a53ce5 fix: default to unknown user-agent 2026-02-09 12:04:55 +00:00
Carl-Gerhard Lindesvärd
6ce9b5dd1b public: feature pages 2026-02-07 16:42:02 +00:00
Carl-Gerhard Lindesvärd
ed8b5c667e fix: how we fetch profiles in the buffer 2026-02-06 13:14:12 +00:00
Carl-Gerhard Lindesvärd
fc3b6fb891 fix(dashboard): improvements for both funnel and conversion chart 2026-02-06 08:22:57 +00:00
Carl-Gerhard Lindesvärd
40b0774ef8 feat: add API_HOST env 2026-02-05 22:56:03 +00:00
Carl-Gerhard Lindesvärd
b0d531d793 fix: try fix revenue issues on overview 2026-02-05 22:54:30 +00:00
Carl-Gerhard Lindesvärd
063e80a659 fix: memo issue on tables 2026-02-05 21:57:58 +00:00
Carl-Gerhard Lindesvärd
c4d76d0a57 fix: fallback to url if no name exists 2026-02-05 21:44:16 +00:00
Carl-Gerhard Lindesvärd
21792bfbfa chore: refactor chart access 2026-02-05 21:43:59 +00:00
Carl-Gerhard Lindesvärd
19a48e86c1 chore: refactor overview access 2026-02-05 21:31:34 +00:00
Carl-Gerhard Lindesvärd
c978904815 fix(dashboard): filter event names when needed 2026-02-05 21:24:54 +00:00
Carl-Gerhard Lindesvärd
7a88b262c0 fix(dashboard): share overview (all widgets didnt work) 2026-02-05 21:24:37 +00:00
Carl-Gerhard Lindesvärd
2d0478d626 fix(redis): avoid info command for sub/pub redis 2026-02-05 21:24:10 +00:00
Carl-Gerhard Lindesvärd
ef8c2a4eee fix(api): reduce event spam with block list 2026-02-05 21:23:48 +00:00
Carl-Gerhard Lindesvärd
02897e11cb fix: favicons 2026-02-05 21:23:24 +00:00
Carl-Gerhard Lindesvärd
05ccbc372c public: add reviews 2026-02-05 13:04:40 +00:00
Carl-Gerhard Lindesvärd
f1c85c53cf fix: mostly UI imporvements 2026-01-30 08:48:40 +01:00
Carl-Gerhard Lindesvärd
18600aa5ab perf: conversion rate 2026-01-29 13:31:22 +01:00
Carl-Gerhard Lindesvärd
2c7edec274 fix: funnel 2026-01-29 13:30:59 +01:00
Carl-Gerhard Lindesvärd
b8469f8431 chore(react-native): bump 1.0.6 2026-01-29 12:27:29 +01:00
Carl-Gerhard Lindesvärd
f6b2c6099f chore(react-native): bump peer dependencies 2026-01-29 12:26:21 +01:00
Carl-Gerhard Lindesvärd
1cde7a3e35 fix(dashboard): time interval still wrong on funnel and retention 2026-01-29 12:21:46 +01:00
Carl-Gerhard Lindesvärd
64bcd5c155 fix: use v2 (no minor or patch) for self-hosting 2026-01-26 13:58:50 +01:00
Carl-Gerhard Lindesvärd
d330053874 chore: fix docker build script to make a semver versions 2026-01-26 13:58:22 +01:00
Carl-Gerhard Lindesvärd
db62919825 fix: salt issues 2026-01-26 12:07:06 +01:00
Carl-Gerhard Lindesvärd
286f8e160b feat: improve webhook integration (customized body and headers) 2026-01-23 15:01:34 +01:00
Carl-Gerhard Lindesvärd
f8f470adf9 fix: notifications on session_start 2026-01-23 10:20:09 +01:00
Carl-Gerhard Lindesvärd
e7c2834ea0 fix: profile metric overflow issues 2026-01-22 22:18:01 +01:00
Carl-Gerhard Lindesvärd
753d6dce4c fix: encode profile ids #280 2026-01-22 22:08:41 +01:00
Carl-Gerhard Lindesvärd
9e5b482447 fix: report intervals 2026-01-22 21:49:13 +01:00
Carl-Gerhard Lindesvärd
32ea28b2f6 feat: improve activity chart on profile 2026-01-22 20:54:08 +01:00
Carl-Gerhard Lindesvärd
b39d076b32 feat: add sortable overview widgets 2026-01-22 20:53:05 +01:00
Carl-Gerhard Lindesvärd
ec5937e55c docs: add widget 2026-01-22 14:43:57 +01:00
Carl-Gerhard Lindesvärd
f83fe7a0fc fix: react email 2026-01-22 12:32:14 +01:00
Carl-Gerhard Lindesvärd
6c56efdf37 fix: dockerfile worker 2026-01-22 11:21:49 +01:00
Carl-Gerhard Lindesvärd
e5be28a49d fix: link 2026-01-22 10:48:11 +01:00
Carl-Gerhard Lindesvärd
e645c094b2 feature: onboarding emails
* wip

* wip

* wip

* fix coderabbit comments

* remove template
2026-01-22 10:38:05 +01:00
Carl-Gerhard Lindesvärd
67301d928c feat: add product hunt badge widget 2026-01-22 10:11:44 +01:00
Carl-Gerhard Lindesvärd
deb3c3d20c chore: add assign product to org script 2026-01-21 21:56:20 +01:00
Carl-Gerhard Lindesvärd
6e997e62f1 docs: add free open source analytics 2026-01-21 11:43:47 +01:00
Carl-Gerhard Lindesvärd
2c5ca8adec fix: validate revenue payload (must be int) 2026-01-21 11:33:15 +01:00
Carl-Gerhard Lindesvärd
3e573ae27f fix: hide billing on self-hosted 2026-01-20 17:41:58 +01:00
Carl-Gerhard Lindesvärd
5b29f7502c wip 2026-01-20 14:39:27 +01:00
Carl-Gerhard Lindesvärd
d32a279949 fix: profileId 2026-01-20 14:08:44 +01:00
Carl-Gerhard Lindesvärd
ed6e5cd334 Revert "fix: test fix broken dashboard #1"
This reverts commit 24ee6b0b6c.

# Conflicts:
#	apps/start/src/integrations/tanstack-query/root-provider.tsx
2026-01-20 14:06:10 +01:00
Carl-Gerhard Lindesvärd
cf1bf95388 fix: allow int for profileId 2026-01-20 13:06:35 +01:00
Carl-Gerhard Lindesvärd
5830277ba9 fix: trigger build on start changes 2026-01-20 12:45:27 +01:00
Carl-Gerhard Lindesvärd
aa13c87e87 feat: add CUSTOM_COOKIE_DOMAIN 2026-01-20 12:43:30 +01:00
Carl-Gerhard Lindesvärd
83c3647f66 fix: test fix broken dashboard #2 2026-01-20 12:34:39 +01:00
Carl-Gerhard Lindesvärd
927613c09d fix: demo mode 2026-01-20 11:41:32 +01:00
Carl-Gerhard Lindesvärd
24ee6b0b6c fix: test fix broken dashboard #1 2026-01-20 10:55:03 +01:00
Carl-Gerhard Lindesvärd
13d8b92cf3 fix: read issues 2026-01-20 06:44:13 +01:00
Carl-Gerhard Lindesvärd
4b2db351c4 fix: issues with sdk and bot detection 2026-01-20 06:44:13 +01:00
Carl-Gerhard Lindesvärd
334adec9f2 chore: remove dragonfly from local docker-compose 2026-01-20 06:44:13 +01:00
Carl-Gerhard Lindesvärd
9a54daae55 fix: disable bullboard without unsetting the env #273 2026-01-20 06:44:12 +01:00
Carl-Gerhard Lindesvärd
7cd5f84c58 fix: allow custom cookie tld via env (COOKIE_TLDS) 2026-01-20 06:14:02 +01:00
Carl-Gerhard Lindesvärd
470ddbe8e7 feat: add manage api for projects, clients and references 2026-01-20 05:53:57 +01:00
Carl-Gerhard Lindesvärd
c63578b35b fix: widgets 2026-01-20 05:53:54 +01:00
Carl-Gerhard Lindesvärd
b5792df69f fix: show project widgets in settings 2026-01-19 21:50:12 +01:00
Carl-Gerhard Lindesvärd
00f2e2937d feat: add stacked option for histogram 2026-01-19 21:41:36 +01:00
Carl-Gerhard Lindesvärd
0d1773eb74 fix: apply new docker image versions to self-hosting 2026-01-15 23:20:07 +01:00
Carl-Gerhard Lindesvärd
ed1c57dbb8 feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode

* fix: handle past_due and unpaid from polar

* wip

* wip

* wip 1

* fix: improve types for chart/reports

* wip share
2026-01-14 09:21:18 +01:00
Carl-Gerhard Lindesvärd
39251c8598 fix: ensure logger never fails on color 2026-01-09 20:05:15 +01:00
Carl-Gerhard Lindesvärd
9a4aa51975 fix: typecheck 2026-01-09 15:03:27 +01:00
Carl-Gerhard Lindesvärd
f008fb58e5 docs: update logos 2026-01-09 14:42:22 +01:00
Carl-Gerhard Lindesvärd
cabfb1f3f0 fix: dashboard improvements and query speed improvements 2026-01-09 14:42:11 +01:00
Carl-Gerhard Lindesvärd
4867260ece bump: sdks 2026-01-07 12:34:11 +01:00
Carl-Gerhard Lindesvärd
87c919f700 chore: add readme to all our sdks (on npm) 2026-01-07 12:31:05 +01:00
Carl-Gerhard Lindesvärd
3c085e445d fix: better validation of events + clean up (#267) 2026-01-07 11:58:11 +01:00
Carl-Gerhard Lindesvärd
6d9e3ce8e5 docs: fix missing nuxt guide 2026-01-07 10:38:00 +01:00
Carl-Gerhard Lindesvärd
f187065d75 chore: release nuxt sdk 2026-01-07 10:30:41 +01:00
Carl-Gerhard Lindesvärd
d5e4518e32 fix: lock file 2026-01-07 10:30:04 +01:00
Carl-Gerhard Lindesvärd
1f088d2208 feat: add nuxt sdk (#260)
* wip

* fix: improve api route for nuxt
2026-01-07 10:28:11 +01:00
Carl-Gerhard Lindesvärd
3bd1f99d28 fix: remove properties from import service (sessions) 2026-01-07 10:16:17 +01:00
Carl-Gerhard Lindesvärd
9a916f3171 fix: sanitize dimension key 2025-12-19 12:47:30 +01:00
Carl-Gerhard Lindesvärd
34cb186ead feat: User Journey 2025-12-19 09:39:25 +01:00
Carl-Gerhard Lindesvärd
5f38560373 feat: insights
* fix: migration for newly created self-hosting instances

* fix: build script

* wip

* wip

* wip

* fix: tailwind css
2025-12-19 09:37:15 +01:00
Carl-Gerhard Lindesvärd
1e4f02fb5e docs: ip lookup 2025-12-16 20:31:44 +01:00
Carl-Gerhard Lindesvärd
3158ebfbda chore: prep v2 self-hosting 2025-12-16 15:36:21 +01:00
Carl-Gerhard Lindesvärd
d7c6e88adc fix: change order keys for clickhouse tables
* wip

* rename

* fix: minor things before merging new order keys

* fix: add maintenance mode

* fix: update order by for session and events

* fix: remove properties from sessions and final migration test

* fix: set end date on migrations

* fix: comments
2025-12-16 12:48:51 +01:00
Carl-Gerhard Lindesvärd
3b61b28290 fix: handle toFloat on numeric fields 2025-12-15 23:05:14 +01:00
Carl-Gerhard Lindesvärd
8dfeaa870c docs: add guides to sitemap 2025-12-15 22:29:56 +01:00
Carl-Gerhard Lindesvärd
329f76b7ce fix: avoid overwrite profile properties (geo etc) when profile is from server 2025-12-15 22:11:07 +01:00
Carl-Gerhard Lindesvärd
3b74d8ae36 feat: show revenue amount on event list (if revenue) 2025-12-15 22:10:45 +01:00
Carl-Gerhard Lindesvärd
a2a53cf9f7 fix: handle revenue better on overview (and remove it from top pages) 2025-12-15 22:10:21 +01:00
keiwanmosaddegh
4e7dc16619 chore: Update README.md with correct local dev instructions (#238)
* Update README.md

* remove the trailing newline

* remove the trailing newline
2025-12-15 11:33:58 +01:00
Carl-Gerhard Lindesvärd
0f9ac4508a fix: update react 2025-12-15 11:26:11 +01:00
Carl-Gerhard Lindesvärd
c46cda12eb docs: fix og for tools 2025-12-15 11:16:56 +01:00
Carl-Gerhard Lindesvärd
546ef6673f docs: fix broken build 2025-12-15 11:14:27 +01:00
Carl-Gerhard Lindesvärd
3b2ed3afb1 bump: sdks 2025-12-15 11:01:13 +01:00
Carl-Gerhard Lindesvärd
95846f80e5 docs: fix types 2025-12-15 10:50:40 +01:00
Carl-Gerhard Lindesvärd
1f5c648afe fix: add sdk logs behind debug flag 2025-12-15 10:50:40 +01:00
Carl-Gerhard Lindesvärd
3d8a3e8997 docs: add guides (#258) 2025-12-15 10:19:16 +01:00
Carl-Gerhard Lindesvärd
28692d82ae fix: add groupmq back to api 2025-12-14 11:34:43 +01:00
Carl-Gerhard Lindesvärd
4bdbb31180 fix: ability to paus buffer based on cron queue 2025-12-10 21:51:49 +01:00
Carl-Gerhard Lindesvärd
be248717d2 Revert "geo: try resolve within node_modules"
This reverts commit 56bd1197a6.
2025-12-10 21:25:59 +01:00
Carl-Gerhard Lindesvärd
56bd1197a6 geo: try resolve within node_modules 2025-12-10 21:11:24 +01:00
Carl-Gerhard Lindesvärd
9ccca322e5 debug: geo db path 2025-12-10 20:48:04 +01:00
Carl-Gerhard Lindesvärd
86a3da869b docs: fix h1 2025-12-10 13:43:00 +01:00
Carl-Gerhard Lindesvärd
ae383001bc docs: add new tools 2025-12-10 13:32:24 +01:00
Carl-Gerhard Lindesvärd
9bedd39e48 fix: remove groupmq from api 2025-12-09 22:45:09 +01:00
Carl-Gerhard Lindesvärd
7131e3f461 fix: log whats needed 2025-12-09 22:44:26 +01:00
Carl-Gerhard Lindesvärd
9665a2593f fix: load geo db once 2025-12-09 22:44:13 +01:00
Carl-Gerhard Lindesvärd
c201bea682 docs: updates onboarding links 2025-12-09 22:38:33 +01:00
Carl-Gerhard Lindesvärd
8312556b38 fix(dashboard): filter on all properties (except on overview) 2025-12-09 21:14:52 +01:00
Carl-Gerhard Lindesvärd
d2b22867b9 fix: bump groupmq 2025-12-09 13:29:33 +01:00
Carl-Gerhard Lindesvärd
2b8bcf1ed7 fix: bump groupmq 2025-12-08 22:13:04 +01:00
Carl-Gerhard Lindesvärd
e22a5b3237 fix: sticky header on overview 2025-12-08 22:12:53 +01:00
Carl-Gerhard Lindesvärd
315e4a59a3 fix: dont throw if session not found for event details 2025-12-08 20:32:55 +01:00
Carl-Gerhard Lindesvärd
969c0bc8fe fix: ensure job id is a clean string 2025-12-08 20:32:29 +01:00
Carl-Gerhard Lindesvärd
4e42689115 fix: join profiles on getFunnelProfiles 2025-12-08 15:02:46 +01:00
Carl-Gerhard Lindesvärd
d3522c51f8 api: improve user-agent parsing 2025-12-08 14:38:11 +01:00
Carl-Gerhard Lindesvärd
abf5353ab3 docs: improvements 2025-12-08 13:53:11 +01:00
Carl-Gerhard Lindesvärd
2dda50fc7c docs: improve pricing 2025-12-08 13:20:07 +01:00
Carl-Gerhard Lindesvärd
cbdb3a62c1 docs: new compliance article 2025-12-08 11:28:31 +01:00
Carl-Gerhard Lindesvärd
4b775ff2c5 fix: improve SEO 2025-12-04 09:46:12 +01:00
Carl-Gerhard Lindesvärd
a37e37c28b docs: fix broken links 2025-12-04 08:56:06 +01:00
Carl-Gerhard Lindesvärd
64afd04f7b fix: pre build static pages 2025-12-04 08:50:01 +01:00
Carl-Gerhard Lindesvärd
2468dc29ff docs: update deps 2025-12-04 08:41:15 +01:00
Carl-Gerhard Lindesvärd
662975dc08 docs: add rust and ruby 2025-12-03 10:26:36 +01:00
Carl-Gerhard Lindesvärd
0a1564c798 docs: update python sdk 2025-12-02 23:37:50 +01:00
Carl-Gerhard Lindesvärd
56b01ca6d8 feat: support filter by numbers 2025-12-02 23:33:41 +01:00
Carl-Gerhard Lindesvärd
e5b9865850 fix: local clickhouse 2025-12-02 23:02:17 +01:00
Carl-Gerhard Lindesvärd
5576519a2a fix: include op1.js for self-hosting #229 2025-12-02 22:27:25 +01:00
Carl-Gerhard Lindesvärd
1f74ab99ae fix: make parse cookie domain handle more unknown domains #241 2025-12-02 22:09:21 +01:00
Carl-Gerhard Lindesvärd
3ae7d1322e fix: re-render issues for report-table 2025-12-02 22:08:43 +01:00
Carl-Gerhard Lindesvärd
e4b919c4da fix: general ui fixes 2025-12-02 21:54:57 +01:00
Carl-Gerhard Lindesvärd
50ef4c0d94 fix: add internal links 2025-12-02 14:06:18 +01:00
Carl-Gerhard Lindesvärd
a7a357eb0f fix: use webp instead of png 2025-12-02 09:41:13 +01:00
Carl-Gerhard Lindesvärd
7adc2903d2 fix: add tracking script 2025-12-02 09:35:25 +01:00
Carl-Gerhard Lindesvärd
ac4429d6d9 feat: new public website 2025-12-02 09:22:36 +01:00
Carl-Gerhard Lindesvärd
e2536774b0 docs: update pricing 2025-11-28 13:37:42 +01:00
Carl-Gerhard Lindesvärd
a39796a829 docs: add sections for popular deployment methods 2025-11-28 12:56:16 +01:00
Carl-Gerhard Lindesvärd
8b31e4cfba fix: add conditionally clickhouse options 2025-11-27 14:59:11 +01:00
Carl-Gerhard Lindesvärd
ae09748e4e fix: unique report count 2025-11-27 13:38:32 +01:00
Carl-Gerhard Lindesvärd
4ef0b1afe2 fix: re-render report table 2025-11-27 13:12:27 +01:00
Carl-Gerhard Lindesvärd
620904b4d4 fix: view profiles, improve chart service 2025-11-27 13:12:14 +01:00
Carl-Gerhard Lindesvärd
d4e3470f7e fix: overflow profile properties 2025-11-27 12:05:47 +01:00
Carl-Gerhard Lindesvärd
d154e12d66 fix: update deps 2025-11-26 12:52:44 +01:00
Carl-Gerhard Lindesvärd
b421474616 feat: report editor
commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Wed Nov 26 12:32:40 2025 +0100

    wip

commit 8cd3b89fa3
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 22:33:58 2025 +0100

    funnel

commit 95af86dc44
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 22:23:25 2025 +0100

    wip

commit 727a218e6b
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 10:18:26 2025 +0100

    conversion wip

commit 958ba535d6
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 10:18:20 2025 +0100

    wip

commit 3bbeb927cc
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 09:18:48 2025 +0100

    wip

commit d99335e2f4
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 18:08:10 2025 +0100

    wip

commit 1fa61b1ae9
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 15:50:28 2025 +0100

    ts

commit 548747d826
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 13:17:01 2025 +0100

    fix typecheck events -> series

commit 7b18544085
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 13:06:46 2025 +0100

    fix report table

commit 57697a5a39
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Sat Nov 22 00:05:13 2025 +0100

    wip

commit 06fb6c4f3c
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Fri Nov 21 11:21:17 2025 +0100

    wip

commit dd71fd4e11
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Thu Nov 20 13:56:58 2025 +0100

    formulas
2025-11-26 12:33:41 +01:00
Carl-Gerhard Lindesvärd
828c8c4f91 fix: add better syntax for nextjs proxy 2025-11-25 12:11:18 +01:00
Carl-Gerhard Lindesvärd
6da8267509 feat: improve nextjs proxying mode 2025-11-25 11:48:12 +01:00
Carl-Gerhard Lindesvärd
86903b1937 fix: nextjs sdk to allow pass query string 2025-11-25 10:38:02 +01:00
Carl-Gerhard Lindesvärd
4f9d66693e bump: sdk 1.0.2 2025-11-25 10:26:26 +01:00
Carl-Gerhard Lindesvärd
32415d31d9 fix: fetch device id 2025-11-25 10:24:11 +01:00
Carl-Gerhard Lindesvärd
f7055c0ebd fix: add revenue to nextjs package and bump 1.0.18 2025-11-25 09:23:11 +01:00
Carl-Gerhard Lindesvärd
c9cf0274af fix: reset exceeded at when upgrading account 2025-11-24 20:27:46 +01:00
Carl-Gerhard Lindesvärd
44184236a8 fix: bump web sdk 2025-11-21 12:16:12 +01:00
Carl-Gerhard Lindesvärd
71270b3493 fix: backward comp cache web script 2025-11-21 12:10:31 +01:00
1410 changed files with 128357 additions and 32748 deletions

170
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,170 @@
# CLAUDE.md
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Openpanel is an open-source web/product analytics platform (Mixpanel alternative). It's a **pnpm monorepo** with apps, packages, tooling, and SDKs.
## Common Commands
```bash
# Development
pnpm dev # Run all services (api, worker, dashboard) in parallel
pnpm dev:public # Run public/docs site only
pnpm dock:up / dock:down # Start/stop Docker (PostgreSQL, Redis, ClickHouse)
# Code quality
pnpm check # Lint check (Biome via Ultracite)
pnpm fix # Auto-fix lint/format issues
pnpm typecheck # Typecheck all packages
# Testing
pnpm test # Run all tests (vitest)
pnpm vitest run <path> # Run a single test file
# Workspace: packages/* and apps/* (excluding apps/start)
# Database
pnpm codegen # Generate Prisma types + geo data
pnpm migrate # Run Prisma migrations (dev)
pnpm migrate:deploy # Deploy migrations (production - never run this)
# Docker utilities
pnpm dock:ch # ClickHouse CLI
pnpm dock:redis # Redis CLI
```
## Architecture
### Apps
| App | Stack | Port | Purpose |
|-----|-------|------|---------|
| `apps/api` | Fastify + tRPC | 3333 | REST/RPC API server |
| `apps/start` | TanStack Start (Vite + React 19) | 3000 | Dashboard SPA |
| `apps/public` | Next.js 16 + Fumadocs | - | Marketing/docs site |
| `apps/worker` | Express + BullMQ | 9999 | Background job processor |
### Key Packages
| Package | Purpose |
|---------|---------|
| `packages/db` | Prisma ORM (PostgreSQL) + ClickHouse client |
| `packages/trpc` | tRPC router definitions, context, middleware |
| `packages/auth` | Authentication (Arctic OAuth, Oslo sessions, argon2) |
| `packages/queue` | BullMQ + GroupMQ job queue definitions |
| `packages/redis` | Redis client + LRU caching |
| `packages/validation` | Zod schemas shared across apps |
| `packages/common` | Shared utilities (date-fns, ua-parser, nanoid) |
| `packages/email` | React Email templates via Resend |
| `packages/sdks/*` | Client SDKs (web, react, next, express, react-native, etc.) |
### Data Flow
1. **Event ingestion**: Client SDKs → `apps/api` (track routes) → Redis queue
2. **Processing**: `apps/worker` picks up jobs from BullMQ, batches events into ClickHouse
3. **Dashboard queries**: `apps/start` → tRPC → `apps/api` → ClickHouse (analytics) / PostgreSQL (config)
4. **Real-time**: WebSocket via Fastify, pub/sub via Redis
### Three-Database Strategy
- **PostgreSQL**: Relational data (users, orgs, projects, dashboards). Managed by Prisma.
- **ClickHouse**: Analytics event storage (OLAP). High-volume reads/writes.
- **Redis**: Caching, job queues (BullMQ), rate limiting, pub/sub.
### Dashboard (apps/start)
Uses TanStack Router with file-based routing (`src/routes/`). State management via Redux Toolkit. UI built on Radix primitives + Tailwind v4. Charts via Recharts. Modals in `src/modals/`.
### API (apps/api)
Fastify server with tRPC integration. Route files in `src/routes/`. Hooks for IP extraction, request logging, timestamps. Built with `tsdown`.
---
## Core Principles
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
### Type Safety & Explicitness
- Use explicit types for function parameters and return values when they enhance clarity
- Prefer `unknown` over `any` when the type is genuinely unknown
- Use const assertions (`as const`) for immutable values and literal types
- Leverage TypeScript's type narrowing instead of type assertions
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
### Modern JavaScript/TypeScript
- Use arrow functions for callbacks and short functions
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
- Prefer template literals over string concatenation
- Use destructuring for object and array assignments
- Use `const` by default, `let` only when reassignment is needed, never `var`
### Async & Promises
- Always `await` promises in async functions - don't forget to use the return value
- Use `async/await` syntax instead of promise chains for better readability
- Handle errors appropriately in async code with try-catch blocks
- Don't use async functions as Promise executors
### React & JSX
- Use function components over class components
- Call hooks at the top level only, never conditionally
- Specify all dependencies in hook dependency arrays correctly
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
- Nest children between opening and closing tags instead of passing as props
- Don't define components inside other components
- Use semantic HTML and ARIA attributes for accessibility:
- Provide meaningful alt text for images
- Use proper heading hierarchy
- Add labels for form inputs
- Include keyboard event handlers alongside mouse events
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
### Error Handling & Debugging
- Remove `console.log`, `debugger`, and `alert` statements from production code
- Throw `Error` objects with descriptive messages, not strings or other values
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
- Prefer early returns over nested conditionals for error cases
### Code Organization
- Keep functions focused and under reasonable cognitive complexity limits
- Extract complex conditions into well-named boolean variables
- Use early returns to reduce nesting
- Prefer simple conditionals over nested ternary operators
- Group related code together and separate concerns
### Security
- Add `rel="noopener"` when using `target="_blank"` on links
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
- Don't use `eval()` or assign directly to `document.cookie`
- Validate and sanitize user input
### Performance
- Avoid spread syntax in accumulators within loops
- Use top-level regex literals instead of creating them in loops
- Prefer specific imports over namespace imports
- Avoid barrel files (index files that re-export everything)
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
### Framework-Specific Guidance
**Next.js:**
- Use Next.js `<Image>` component for images
- Use `next/head` or App Router metadata API for head elements
- Use Server Components for async data fetching instead of async Client Components
**React 19+:**
- Use ref as a prop instead of `React.forwardRef`
**Solid/Svelte/Vue/Qwik:**
- Use `class` and `for` attributes (not `className` or `htmlFor`)

View File

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

View File

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

View File

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

View File

@@ -3,54 +3,37 @@ name: Docker Build and Push
on:
workflow_dispatch:
push:
# branches: [ "main" ]
paths:
- "apps/api/**"
- "apps/worker/**"
paths-ignore:
# README and docs
- "**/README*"
- "**/readme*"
- "**/*.md"
- "**/docs/**"
- "**/CHANGELOG*"
- "**/LICENSE*"
# Test files
- "**/*.test.*"
- "**/*.spec.*"
- "**/__tests__/**"
- "**/tests/**"
# SDKs (published separately)
- "packages/sdks/**"
# Public app (docs/marketing, not part of Docker deploy)
- "apps/public/**"
- "packages/**"
- "!packages/sdks/**"
- "**Dockerfile"
- ".github/workflows/**"
# Dev / tooling
- "**/.vscode/**"
- "**/.cursor/**"
- "**/.env.example"
- "**/.env.*.example"
- "**/.gitignore"
- "**/.eslintignore"
- "**/.prettierignore"
env:
repo_owner: "openpanel-dev"
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
worker: ${{ steps.filter.outputs.worker }}
public: ${{ steps.filter.outputs.public }}
dashboard: ${{ steps.filter.outputs.dashboard }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
base: "main"
filters: |
api:
- 'apps/api/**'
- 'packages/**'
- '.github/workflows/**'
worker:
- 'apps/worker/**'
- 'packages/**'
- '.github/workflows/**'
public:
- 'apps/public/**'
- 'packages/**'
- '.github/workflows/**'
dashboard:
- 'apps/start/**'
- 'packages/**'
- '.github/workflows/**'
lint-and-test:
needs: changes
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' || needs.changes.outputs.dashboard == 'true' }}
runs-on: ubuntu-latest
services:
redis:
@@ -105,8 +88,7 @@ jobs:
permissions:
packages: write
contents: write
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.api == 'true' }}
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -166,8 +148,7 @@ jobs:
permissions:
packages: write
contents: write
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.worker == 'true' }}
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -227,8 +208,7 @@ jobs:
permissions:
packages: write
contents: write
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.dashboard == 'true' }}
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- name: Checkout repository

1
.gitignore vendored
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

@@ -17,7 +17,7 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "vscode.json-language-features"
},
"editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [

50
.zed/settings.json Normal file
View File

@@ -0,0 +1,50 @@
{
"formatter": "language_server",
"format_on_save": "on",
"lsp": {
"typescript-language-server": {
"settings": {
"typescript": {
"preferences": {
"includePackageJsonAutoImports": "on"
}
}
}
}
},
"languages": {
"JavaScript": {
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"TypeScript": {
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"TSX": {
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
}
}
}

View File

@@ -1,4 +1,4 @@
![hero](apps/public/public/ogimage.jpg)
![hero](apps/public/public/ogimage.png)
<p align="center">
<h1 align="center"><b>Openpanel</b></h1>
@@ -28,6 +28,7 @@ Openpanel is an open-source web and product analytics platform that combines the
## ✨ Features
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
- **🎬 Session Replay**: Record and replay user sessions with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -48,6 +49,7 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -56,9 +58,10 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅*** Plausible has simple goals
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## Stack
@@ -98,6 +101,10 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
### Start
```bash
pnpm install
cp .env.example .env
echo "API_URL=http://localhost:3333" > apps/start/.env
pnpm dock:up
pnpm codegen
pnpm migrate:deploy # once to setup the db
@@ -110,4 +117,5 @@ You can now access the following:
- API: https://api.localhost:3333
- Bullboard (queue): http://localhost:9999
- `pnpm dock:ch` to access clickhouse terminal
- `pnpm dock:redis` to access redis terminal
- `pnpm dock:redis` to access redis terminal
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ FROM node:${NODE_VERSION}-slim AS base
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
ENV COREPACK_INTEGRITY_KEYS=0
RUN corepack enable && apt-get update && \
RUN rm -f /usr/local/bin/pnpm /usr/local/bin/pnpx && npm install -g pnpm@10.6.2 && apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
openssl \
@@ -38,11 +38,10 @@ COPY packages/redis/package.json packages/redis/
COPY packages/logger/package.json packages/logger/
COPY packages/common/package.json packages/common/
COPY packages/payments/package.json packages/payments/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/js-runtime/package.json packages/js-runtime/
COPY patches ./patches
# BUILD
@@ -107,10 +106,10 @@ COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen

View File

@@ -8,6 +8,7 @@
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"test:manage": "jiti scripts/test-manage-api.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -38,7 +39,7 @@
"fastify": "^5.6.1",
"fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0",
"groupmq": "1.1.0-next.6",
"groupmq": "catalog:",
"jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1",
"sharp": "^0.33.5",
@@ -46,13 +47,12 @@
"sqlstring": "^2.3.3",
"superjson": "^1.13.3",
"svix": "^1.24.0",
"url-metadata": "^4.1.1",
"url-metadata": "^5.4.1",
"uuid": "^9.0.1",
"zod": "catalog:"
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9",

View File

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

View File

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

View File

@@ -0,0 +1,354 @@
/**
* One-off script to test all /manage/ API endpoints
*
* Usage:
* pnpm test:manage
* or
* pnpm jiti scripts/test-manage-api.ts
*
* Set API_URL environment variable to test against a different server:
* API_URL=http://localhost:3000 pnpm test:manage
*/
const CLIENT_ID = process.env.CLIENT_ID!;
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
if (!(CLIENT_ID && CLIENT_SECRET)) {
console.error('CLIENT_ID and CLIENT_SECRET must be set');
process.exit(1);
}
interface TestResult {
name: string;
method: string;
url: string;
status: number;
success: boolean;
error?: string;
data?: any;
}
const results: TestResult[] = [];
async function makeRequest(
method: string,
path: string,
body?: any
): Promise<TestResult> {
const url = `${API_BASE_URL}${path}`;
const headers: Record<string, string> = {
'openpanel-client-id': CLIENT_ID,
'openpanel-client-secret': CLIENT_SECRET,
};
// Only set Content-Type if there's a body
if (body) {
headers['Content-Type'] = 'application/json';
}
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => ({}));
return {
name: `${method} ${path}`,
method,
url,
status: response.status,
success: response.ok,
error: response.ok ? undefined : data.message || 'Request failed',
data: response.ok ? data : undefined,
};
} catch (error) {
return {
name: `${method} ${path}`,
method,
url,
status: 0,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async function testProjects() {
console.log('\n📁 Testing Projects endpoints...\n');
// Create project
const createResult = await makeRequest('POST', '/manage/projects', {
name: `Test Project ${Date.now()}`,
domain: 'https://example.com',
cors: ['https://example.com', 'https://www.example.com'],
crossDomain: false,
types: ['website'],
});
results.push(createResult);
console.log(
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
);
if (createResult.error) {
console.log(` Error: ${createResult.error}`);
}
const projectId = createResult.data?.data?.id;
const clientId = createResult.data?.data?.client?.id;
const clientSecret = createResult.data?.data?.client?.secret;
if (projectId) {
console.log(` Created project: ${projectId}`);
if (clientId) {
console.log(` Created client: ${clientId}`);
}
if (clientSecret) {
console.log(` Client secret: ${clientSecret}`);
}
}
// List projects
const listResult = await makeRequest('GET', '/manage/projects');
results.push(listResult);
console.log(
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} projects`);
}
if (projectId) {
// Get project
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
results.push(getResult);
console.log(
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
);
// Update project
const updateResult = await makeRequest(
'PATCH',
`/manage/projects/${projectId}`,
{
name: 'Updated Test Project',
crossDomain: true,
}
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
);
// Delete project (soft delete)
const deleteResult = await makeRequest(
'DELETE',
`/manage/projects/${projectId}`
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
);
}
return { projectId, clientId };
}
async function testClients(projectId?: string) {
console.log('\n🔑 Testing Clients endpoints...\n');
// Create client
const createResult = await makeRequest('POST', '/manage/clients', {
name: `Test Client ${Date.now()}`,
projectId: projectId || undefined,
type: 'read',
});
results.push(createResult);
console.log(
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
);
if (createResult.error) {
console.log(` Error: ${createResult.error}`);
}
const clientId = createResult.data?.data?.id;
const clientSecret = createResult.data?.data?.secret;
if (clientId) {
console.log(` Created client: ${clientId}`);
if (clientSecret) {
console.log(` Client secret: ${clientSecret}`);
}
}
// List clients
const listResult = await makeRequest(
'GET',
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients'
);
results.push(listResult);
console.log(
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} clients`);
}
if (clientId) {
// Get client
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
results.push(getResult);
console.log(
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
);
// Update client
const updateResult = await makeRequest(
'PATCH',
`/manage/clients/${clientId}`,
{
name: 'Updated Test Client',
}
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
);
// Delete client
const deleteResult = await makeRequest(
'DELETE',
`/manage/clients/${clientId}`
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
);
}
}
async function testReferences(projectId?: string) {
console.log('\n📚 Testing References endpoints...\n');
if (!projectId) {
console.log(' ⚠️ Skipping references tests - no project ID available');
return;
}
// Create reference
const createResult = await makeRequest('POST', '/manage/references', {
projectId,
title: `Test Reference ${Date.now()}`,
description: 'This is a test reference',
datetime: new Date().toISOString(),
});
results.push(createResult);
console.log(
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
);
if (createResult.error) {
console.log(` Error: ${createResult.error}`);
}
const referenceId = createResult.data?.data?.id;
if (referenceId) {
console.log(` Created reference: ${referenceId}`);
}
// List references
const listResult = await makeRequest(
'GET',
`/manage/references?projectId=${projectId}`
);
results.push(listResult);
console.log(
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} references`);
}
if (referenceId) {
// Get reference
const getResult = await makeRequest(
'GET',
`/manage/references/${referenceId}`
);
results.push(getResult);
console.log(
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
);
// Update reference
const updateResult = await makeRequest(
'PATCH',
`/manage/references/${referenceId}`,
{
title: 'Updated Test Reference',
description: 'Updated description',
datetime: new Date().toISOString(),
}
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
);
// Delete reference
const deleteResult = await makeRequest(
'DELETE',
`/manage/references/${referenceId}`
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
);
}
}
async function main() {
console.log('🚀 Testing Manage API Endpoints\n');
console.log(`API Base URL: ${API_BASE_URL}`);
console.log(`Client ID: ${CLIENT_ID}\n`);
try {
// Test projects first (creates a project we can use for other tests)
const { projectId } = await testProjects();
// Test clients
await testClients(projectId);
// Test references (requires a project)
await testReferences(projectId);
// Summary
console.log(`\n${'='.repeat(60)}`);
console.log('📊 Test Summary\n');
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
console.log(`Total tests: ${results.length}`);
console.log(`✅ Successful: ${successful}`);
console.log(`❌ Failed: ${failed}\n`);
if (failed > 0) {
console.log('Failed tests:');
results
.filter((r) => !r.success)
.forEach((r) => {
console.log(`${r.name} (${r.status})`);
if (r.error) {
console.log(` Error: ${r.error}`);
}
});
}
} catch (error) {
console.error('Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);

View File

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

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

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

View File

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

View File

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

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 { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import { zChartEvent, zChartInput } from '@openpanel/validation';
import { omit } from 'ramda';
import { zChartEvent, zReport } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { parseQueryString } from '@/utils/parse-zod-query-string';
async function getProjectId(
request: FastifyRequest<{
@@ -22,8 +20,7 @@ async function getProjectId(
project_id?: string;
projectId?: string;
};
}>,
reply: FastifyReply,
}>
) {
let projectId = request.query.projectId || request.query.project_id;
@@ -74,10 +71,22 @@ const eventsScheme = z.object({
page: z.coerce.number().optional().default(1),
limit: z.coerce.number().optional().default(50),
includes: z
.preprocess(
(arg) => (typeof arg === 'string' ? [arg] : arg),
z.array(z.string()),
)
.preprocess((arg) => {
if (arg == null) {
return undefined;
}
if (Array.isArray(arg)) {
return arg;
}
if (typeof arg === 'string') {
const parts = arg
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts;
}
return arg;
}, z.array(z.string()))
.optional(),
});
@@ -85,7 +94,7 @@ export async function events(
request: FastifyRequest<{
Querystring: z.infer<typeof eventsScheme>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const query = eventsScheme.safeParse(request.query);
@@ -97,7 +106,7 @@ export async function events(
});
}
const projectId = await getProjectId(request, reply);
const projectId = await getProjectId(request);
const limit = query.data.limit;
const page = Math.max(query.data.page, 1);
const take = Math.max(Math.min(limit, 1000), 1);
@@ -118,20 +127,20 @@ export async function events(
meta: false,
...query.data.includes?.reduce(
(acc, key) => ({ ...acc, [key]: true }),
{},
{}
),
},
};
const [data, totalCount] = await Promise.all([
getEventList(options),
getEventsCountCached(omit(['cursor', 'take'], options)),
getEventsCount(options),
]);
reply.send({
meta: {
count: data.length,
totalCount: totalCount,
totalCount,
pages: Math.ceil(totalCount / options.take),
current: cursor + 1,
},
@@ -139,7 +148,7 @@ export async function events(
});
}
const chartSchemeFull = zChartInput
const chartSchemeFull = zReport
.pick({
breakdowns: true,
interval: true,
@@ -151,21 +160,34 @@ const chartSchemeFull = zChartInput
.extend({
project_id: z.string().optional(),
projectId: z.string().optional(),
events: z.array(
z.object({
name: z.string(),
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
),
series: z
.array(
z.object({
name: z.string(),
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
events: z
.array(
z.object({
name: z.string(),
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
})
)
.optional(),
});
export async function charts(
request: FastifyRequest<{
Querystring: Record<string, string>;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
@@ -177,11 +199,19 @@ export async function charts(
});
}
const projectId = await getProjectId(request, reply);
const projectId = await getProjectId(request);
const { timezone } = await getSettingsForProject(projectId);
const { events, ...rest } = query.data;
const { events, series, ...rest } = query.data;
return getChart({
// Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).map((event: any) => ({
...event,
type: event.type ?? 'event',
segment: event.segment ?? 'event',
filters: event.filters ?? [],
}));
return ChartEngine.execute({
...rest,
startDate: rest.startDate
? DateTime.fromISO(rest.startDate)
@@ -194,11 +224,7 @@ export async function charts(
.toFormat('yyyy-MM-dd HH:mm:ss')
: undefined,
projectId,
events: events.map((event) => ({
...event,
segment: event.segment ?? 'event',
filters: event.filters ?? [],
})),
series: eventSeries,
chartType: 'linear',
metric: 'sum',
});

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

View File

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

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

@@ -0,0 +1,643 @@
import crypto from 'node:crypto';
import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server';
import {
db,
getClientByIdCached,
getId,
getProjectByIdCached,
} from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
// Validation schemas
const zCreateProject = z.object({
name: z.string().min(1),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
cors: z.array(z.string()).default([]),
crossDomain: z.boolean().optional().default(false),
types: z
.array(z.enum(['website', 'app', 'backend']))
.optional()
.default([]),
});
const zUpdateProject = z.object({
name: z.string().min(1).optional(),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
cors: z.array(z.string()).optional(),
crossDomain: z.boolean().optional(),
allowUnsafeRevenueTracking: z.boolean().optional(),
});
const zCreateClient = z.object({
name: z.string().min(1),
projectId: z.string().optional(),
type: z.enum(['read', 'write', 'root']).optional().default('write'),
});
const zUpdateClient = z.object({
name: z.string().min(1).optional(),
});
const zCreateReference = z.object({
projectId: z.string(),
title: z.string().min(1),
description: z.string().optional(),
datetime: z.string(),
});
const zUpdateReference = z.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
datetime: z.string().optional(),
});
// Projects CRUD
export async function listProjects(
request: FastifyRequest,
reply: FastifyReply
) {
const projects = await db.project.findMany({
where: {
organizationId: request.client!.organizationId,
deleteAt: null,
},
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: projects });
}
export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const project = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
reply.send({ data: project });
}
export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply
) {
const parsed = zCreateProject.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { name, domain, cors, crossDomain, types } = parsed.data;
// Generate a default client secret
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const clientData = {
organizationId: request.client!.organizationId,
name: 'First client',
type: 'write' as const,
secret: await hashPassword(secret),
};
const project = await db.project.create({
data: {
id: await getId('project', name),
organizationId: request.client!.organizationId,
name,
domain: domain ? stripTrailingSlash(domain) : null,
cors: cors.map((c) => stripTrailingSlash(c)),
crossDomain: crossDomain ?? false,
allowUnsafeRevenueTracking: false,
filters: [],
types,
clients: {
create: clientData,
},
},
include: {
clients: {
select: {
id: true,
},
},
},
});
await Promise.all([
getProjectByIdCached.clear(project.id),
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
]);
reply.send({
data: {
...project,
client: project.clients[0]
? {
id: project.clients[0].id,
secret,
}
: null,
},
});
}
export async function updateProject(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateProject>;
}>,
reply: FastifyReply
) {
const parsed = zUpdateProject.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify project exists and belongs to organization
const existing = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
include: {
clients: {
select: {
id: true,
},
},
},
});
if (!existing) {
throw new HttpError('Project not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.name !== undefined) {
updateData.name = parsed.data.name;
}
if (parsed.data.domain !== undefined) {
updateData.domain = parsed.data.domain
? stripTrailingSlash(parsed.data.domain)
: null;
}
if (parsed.data.cors !== undefined) {
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
}
if (parsed.data.crossDomain !== undefined) {
updateData.crossDomain = parsed.data.crossDomain;
}
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
updateData.allowUnsafeRevenueTracking =
parsed.data.allowUnsafeRevenueTracking;
}
const project = await db.project.update({
where: {
id: request.params.id,
},
data: updateData,
});
await Promise.all([
getProjectByIdCached.clear(project.id),
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
]);
reply.send({ data: project });
}
export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const project = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
await db.project.update({
where: {
id: request.params.id,
},
data: {
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
await getProjectByIdCached.clear(request.params.id);
reply.send({ success: true });
}
// Clients CRUD
export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply
) {
const where: any = {
organizationId: request.client!.organizationId,
};
if (request.query.projectId) {
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: request.query.projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
where.projectId = request.query.projectId;
}
const clients = await db.client.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: clients });
}
export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const client = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!client) {
throw new HttpError('Client not found', { status: 404 });
}
reply.send({ data: client });
}
export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply
) {
const parsed = zCreateClient.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { name, projectId, type } = parsed.data;
// If projectId is provided, verify it belongs to organization
if (projectId) {
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
}
// Generate secret
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const client = await db.client.create({
data: {
organizationId: request.client!.organizationId,
projectId: projectId || null,
name,
type: type || 'write',
secret: await hashPassword(secret),
},
});
await getClientByIdCached.clear(client.id);
reply.send({
data: {
...client,
secret, // Return plain secret only once
},
});
}
export async function updateClient(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateClient>;
}>,
reply: FastifyReply
) {
const parsed = zUpdateClient.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify client exists and belongs to organization
const existing = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!existing) {
throw new HttpError('Client not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.name !== undefined) {
updateData.name = parsed.data.name;
}
const client = await db.client.update({
where: {
id: request.params.id,
},
data: updateData,
});
await getClientByIdCached.clear(client.id);
reply.send({ data: client });
}
export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const client = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!client) {
throw new HttpError('Client not found', { status: 404 });
}
await db.client.delete({
where: {
id: request.params.id,
},
});
await getClientByIdCached.clear(request.params.id);
reply.send({ success: true });
}
// References CRUD
export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply
) {
const where: any = {};
if (request.query.projectId) {
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: request.query.projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
where.projectId = request.query.projectId;
} else {
// If no projectId, get all projects in org and filter references
const projects = await db.project.findMany({
where: {
organizationId: request.client!.organizationId,
},
select: { id: true },
});
where.projectId = {
in: projects.map((p) => p.id),
};
}
const references = await db.reference.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: references });
}
export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const reference = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!reference) {
throw new HttpError('Reference not found', { status: 404 });
}
if (reference.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
reply.send({ data: reference });
}
export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply
) {
const parsed = zCreateReference.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { projectId, title, description, datetime } = parsed.data;
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
const reference = await db.reference.create({
data: {
projectId,
title,
description: description || null,
date: new Date(datetime),
},
});
reply.send({ data: reference });
}
export async function updateReference(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateReference>;
}>,
reply: FastifyReply
) {
const parsed = zUpdateReference.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify reference exists and belongs to organization
const existing = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!existing) {
throw new HttpError('Reference not found', { status: 404 });
}
if (existing.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.title !== undefined) {
updateData.title = parsed.data.title;
}
if (parsed.data.description !== undefined) {
updateData.description = parsed.data.description ?? null;
}
if (parsed.data.datetime !== undefined) {
updateData.date = new Date(parsed.data.datetime);
}
const reference = await db.reference.update({
where: {
id: request.params.id,
},
data: updateData,
});
reply.send({ data: reference });
}
export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const reference = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!reference) {
throw new HttpError('Reference not found', { status: 404 });
}
if (reference.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
await db.reference.delete({
where: {
id: request.params.id,
},
});
reply.send({ success: true });
}

View File

@@ -1,16 +1,15 @@
import crypto from 'node:crypto';
import { logger } from '@/utils/logger';
import { parseUrlMeta } from '@/utils/parseUrlMeta';
import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp';
import {
DEFAULT_IP_HEADER_ORDER,
getClientIpFromHeaders,
} from '@openpanel/common/server/get-client-ip';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { ch, chQuery, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getCache, getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp';
import { logger } from '@/utils/logger';
import { parseUrlMeta } from '@/utils/parseUrlMeta';
interface GetFaviconParams {
url: string;
@@ -29,7 +28,9 @@ function createCacheKey(url: string, prefix = 'favicon'): string {
function validateUrl(raw?: string): URL | null {
try {
if (!raw) throw new Error('Missing ?url');
if (!raw) {
throw new Error('Missing ?url');
}
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http/https URLs are allowed');
@@ -42,7 +43,7 @@ function validateUrl(raw?: string): URL | null {
// Binary cache functions (more efficient than base64)
async function getFromCacheBinary(
key: string,
key: string
): Promise<{ buffer: Buffer; contentType: string } | null> {
const redis = getRedisCache();
const [bufferBase64, contentType] = await Promise.all([
@@ -50,14 +51,16 @@ async function getFromCacheBinary(
redis.get(`${key}:ctype`),
]);
if (!bufferBase64 || !contentType) return null;
if (!(bufferBase64 && contentType)) {
return null;
}
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
}
async function setToCacheBinary(
key: string,
buffer: Buffer,
contentType: string,
contentType: string
): Promise<void> {
const redis = getRedisCache();
await Promise.all([
@@ -68,10 +71,10 @@ async function setToCacheBinary(
// Fetch image with timeout and size limits
async function fetchImage(
url: URL,
url: URL
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
try {
const response = await fetch(url.toString(), {
@@ -118,7 +121,11 @@ async function fetchImage(
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
return (
url.toLowerCase().endsWith('.ico') ||
contentType === 'image/x-icon' ||
contentType === 'image/vnd.microsoft.icon'
);
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
@@ -128,7 +135,7 @@ function isSvgFile(url: string, contentType?: string): boolean {
async function processImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
contentType?: string
): Promise<Buffer> {
// If it's an ICO file, just return it as-is (no conversion needed)
if (originalUrl && isIcoFile(originalUrl, contentType)) {
@@ -171,28 +178,18 @@ async function processImage(
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
throw error;
}
}
// Create a simple transparent fallback image when Sharp can't process the original
function createFallbackImage(): Buffer {
// 1x1 transparent PNG
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
}
// Process OG image with Sharp (resize to 300px width)
async function processOgImage(
buffer: Buffer,
originalUrl?: string,
contentType?: string,
contentType?: string
): Promise<Buffer> {
// If buffer is small enough, return it as-is
if (buffer.length < 10000) {
if (buffer.length < 10_000) {
logger.debug('Serving OG image directly without processing', {
originalUrl,
bufferSize: buffer.length,
@@ -216,8 +213,7 @@ async function processOgImage(
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
throw error;
}
}
@@ -234,12 +230,18 @@ export async function getFavicon(
request: FastifyRequest<{
Querystring: GetFaviconParams;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
logger.info('getFavicon', {
url: request.query.url,
});
const url = validateUrl(request.query.url);
if (!url) {
return createFallbackImage();
return reply
.status(404)
.header('Content-Type', 'text/plain')
.send('Not found');
}
const cacheKey = createCacheKey(url.toString());
@@ -253,41 +255,110 @@ export async function getFavicon(
}
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
logger.info('before parseUrlMeta', {
url: url.toString(),
});
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
logger.info('parseUrlMeta result', {
url: url.toString(),
favicon: meta?.favicon,
});
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
// Try standard favicon location first
const { origin } = url;
imageUrl = new URL(`${origin}/favicon.ico`);
}
}
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Fetching favicon', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
});
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
// Fetch the image
let { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Favicon fetch result', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
status,
bufferLength: buffer.length,
contentType,
});
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
// try DuckDuckGo's favicon service as a fallback
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
const { hostname } = url;
const duckduckgoUrl = new URL(
`https://icons.duckduckgo.com/ip3/${hostname}.ico`
);
logger.info('Trying DuckDuckGo favicon service', {
originalUrl: url.toString(),
duckduckgoUrl: duckduckgoUrl.toString(),
});
const duckduckgoResult = await fetchImage(duckduckgoUrl);
buffer = duckduckgoResult.buffer;
contentType = duckduckgoResult.contentType;
status = duckduckgoResult.status;
imageUrl = duckduckgoUrl;
logger.info('DuckDuckGo favicon result', {
status,
bufferLength: buffer.length,
contentType,
});
}
// Accept any response as long as we have valid image data
if (buffer.length === 0) {
return reply
.status(404)
.header('Content-Type', 'text/plain')
.send('Not found');
}
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
const processedBuffer = await processImage(
buffer,
imageUrl.toString(),
contentType,
contentType
);
logger.info('Favicon processing result', {
originalUrl: url.toString(),
originalBufferLength: buffer.length,
processedBufferLength: processedBuffer.length,
});
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const responseContentType = isIco ? 'image/x-icon' : contentType;
const isSvg = isSvgFile(imageUrl.toString(), contentType);
let responseContentType = contentType;
if (isIco) {
responseContentType = 'image/x-icon';
} else if (isSvg) {
responseContentType = 'image/svg+xml';
} else if (
processedBuffer.length < 5000 &&
buffer.length === processedBuffer.length
) {
// Image was returned as-is, keep original content type
responseContentType = contentType;
} else {
// Image was processed by Sharp, it's now a PNG
responseContentType = 'image/png';
}
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
@@ -312,7 +383,7 @@ export async function getFavicon(
export async function clearFavicons(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const redis = getRedisCache();
const keys = await redis.keys('favicon:*');
@@ -328,7 +399,7 @@ export async function clearFavicons(
export async function clearOgImages(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const redis = getRedisCache();
const keys = await redis.keys('og:*');
@@ -349,7 +420,7 @@ export async function ping(
count: number;
};
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
await ch.insert({
@@ -381,10 +452,10 @@ export async function ping(
export async function stats(request: FastifyRequest, reply: FastifyReply) {
const res = await getCache('api:stats', 60 * 60, async () => {
const projects = await chQuery<{ project_id: string; count: number }>(
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`,
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`
);
const last24h = await chQuery<{ count: number }>(
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`
);
return { projects, last24hCount: last24h[0]?.count || 0 };
});
@@ -406,7 +477,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
ip,
geo: await getGeoLocation(ip),
};
}),
})
);
if (!ip) {
@@ -424,7 +495,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
acc[other.header] = other;
return acc;
},
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>
),
});
}
@@ -435,7 +506,7 @@ export async function getOgImage(
url: string;
};
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
const url = validateUrl(request.query.url);
@@ -479,7 +550,7 @@ export async function getOgImage(
const processedBuffer = await processOgImage(
buffer,
imageUrl.toString(),
contentType,
contentType
);
// Cache the result

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

@@ -1,19 +1,18 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda';
import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import type {
IncrementProfilePayload,
UpdateProfilePayload,
} from '@openpanel/sdk';
DeprecatedIncrementProfilePayload,
DeprecatedUpdateProfilePayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda';
export async function updateProfile(
request: FastifyRequest<{
Body: UpdateProfilePayload;
Body: DeprecatedUpdateProfilePayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const payload = request.body;
const projectId = request.client!.projectId;
@@ -52,9 +51,9 @@ export async function updateProfile(
export async function incrementProfileProperty(
request: FastifyRequest<{
Body: IncrementProfilePayload;
Body: DeprecatedIncrementProfilePayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { profileId, property, value } = request.body;
const projectId = request.client!.projectId;
@@ -69,7 +68,7 @@ export async function incrementProfileProperty(
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
10
);
if (Number.isNaN(parsed)) {
@@ -79,7 +78,7 @@ export async function incrementProfileProperty(
profile.properties = assocPath(
property.split('.'),
parsed + value,
profile.properties,
profile.properties
);
await upsertProfile({
@@ -94,9 +93,9 @@ export async function incrementProfileProperty(
export async function decrementProfileProperty(
request: FastifyRequest<{
Body: IncrementProfilePayload;
Body: DeprecatedIncrementProfilePayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const { profileId, property, value } = request.body;
const projectId = request.client?.projectId;
@@ -111,7 +110,7 @@ export async function decrementProfileProperty(
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
10
);
if (Number.isNaN(parsed)) {
@@ -121,7 +120,7 @@ export async function decrementProfileProperty(
profile.properties = assocPath(
property.split('.'),
parsed - value,
profile.properties,
profile.properties
);
await upsertProfile({

View File

@@ -1,19 +1,33 @@
import { generateId, slug } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import {
getProfileById,
getSalts,
groupBuffer,
replayBuffer,
upsertProfile,
} from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import {
type EventsQueuePayloadIncomingEvent,
getEventsGroupQueueShard,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import {
type IAssignGroupPayload,
type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload,
type IIncrementPayload,
type IReplayPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { generateId } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
TrackPayload,
} from '@openpanel/sdk';
import { HttpError } from '@/utils/errors';
import { getDeviceId } from '@/utils/ids';
export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries(
@@ -25,36 +39,40 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
'openpanel-client-id',
'request-id',
],
headers,
),
headers
)
).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value ? String(value) : undefined,
}),
{},
{}
);
}
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
if (body.type === 'track') {
const identity = body.payload.properties?.__identify as
| IIdentifyPayload
| undefined;
return (
identity ||
(body?.payload?.profileId
if (identity) {
return identity;
}
return body.payload.profileId
? {
profileId: body.payload.profileId,
profileId: String(body.payload.profileId),
}
: undefined)
);
: undefined;
}
return undefined;
}
export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'],
payload: ITrackHandlerPayload['payload']
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
@@ -81,7 +99,7 @@ export function getTimestamp(
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// isTimestampFromThePast is true only if timestamp is older than 1 hour
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
@@ -91,203 +109,141 @@ export function getTimestamp(
};
}
export async function handler(
request: FastifyRequest<{
Body: TrackHandlerPayload;
}>,
reply: FastifyReply,
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
'properties' in request.body.payload &&
request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'];
const projectId = request.client?.projectId;
if (!projectId) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
}
const identity = getIdentity(request.body);
const profileId = identity?.profileId;
const overrideDeviceId = (() => {
const deviceId =
'properties' in request.body.payload
? request.body.payload.properties?.__deviceId
: undefined;
if (typeof deviceId === 'string') {
return deviceId;
}
return undefined;
})();
// We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload
if (profileId) {
request.body.payload.profileId = profileId;
}
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
const promises = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (identity && Object.keys(identity).length > 1) {
promises.push(
identify({
payload: identity,
projectId,
geo,
ua,
}),
);
}
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises);
break;
}
case 'identify': {
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
projectId,
geo,
ua,
});
break;
}
case 'alias': {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
case 'increment': {
await increment({
payload: request.body.payload,
projectId,
});
break;
}
case 'decrement': {
await decrement({
payload: request.body.payload,
projectId,
});
break;
}
default: {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
}
reply.status(200).send();
interface TrackContext {
projectId: string;
ip: string;
ua?: string;
headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload;
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
geo: GeoLocation;
}
async function track({
payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers,
timestamp,
isTimestampFromThePast,
}: {
payload: TrackPayload;
currentDeviceId: string;
previousDeviceId: string;
projectId: string;
geo: GeoLocation;
headers: Record<string, string | undefined>;
timestamp: number;
isTimestampFromThePast: boolean;
}) {
async function buildContext(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
validatedBody: ITrackHandlerPayload
): Promise<TrackContext> {
const projectId = request.client?.projectId;
if (!projectId) {
throw new HttpError('Missing projectId', { status: 400 });
}
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
const ip =
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
? (validatedBody.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const headers = getStringHeaders(request.headers);
const identity = getIdentity(validatedBody);
const profileId = identity?.profileId;
if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId;
}
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 deviceIdResult = await getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId,
});
return {
projectId,
ip,
ua,
headers,
timestamp: {
value: timestamp.timestamp,
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
deviceId: deviceIdResult.deviceId,
sessionId: deviceIdResult.sessionId,
session: deviceIdResult.session,
geo,
};
}
async function handleTrack(
payload: ITrackPayload,
context: TrackContext
): Promise<void> {
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()}`
: currentDeviceId;
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
: undefined
: deviceId;
const jobId = [
slug(payload.name),
timestamp.value,
projectId,
deviceId,
groupId,
]
.filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
const promises: Promise<unknown>[] = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (context.identity && Object.keys(context.identity).length > 1) {
promises.push(handleIdentify(context.identity, context));
}
promises.push(
getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value,
data: {
projectId,
headers,
event: {
...payload,
groups: payload.groups ?? [],
timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast,
},
uaInfo,
geo,
deviceId,
sessionId,
session,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
groupId,
jobId,
})
);
await Promise.all(promises);
}
async function identify({
payload,
projectId,
geo,
ua,
}: {
payload: IdentifyPayload;
projectId: string;
geo: GeoLocation;
ua?: string;
}) {
async function handleIdentify(
payload: IIdentifyPayload,
context: TrackContext
): Promise<void> {
const { projectId, geo, ua } = context;
const uaInfo = parseUserAgent(ua, payload.properties);
await upsertProfile({
...payload,
@@ -312,32 +268,30 @@ async function identify({
});
}
async function increment({
payload,
projectId,
}: {
payload: IncrementPayload;
projectId: string;
}) {
async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload,
projectId: string,
direction: 1 | -1
): Promise<void> {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new Error('Not found');
throw new HttpError('Profile not found', { status: 404 });
}
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
10
);
if (Number.isNaN(parsed)) {
throw new Error('Not number');
throw new HttpError('Property value is not a number', { status: 400 });
}
profile.properties = assocPath(
property.split('.'),
parsed + (value || 1),
profile.properties,
parsed + direction * (value || 1),
profile.properties
);
await upsertProfile({
@@ -348,45 +302,142 @@ async function increment({
});
}
async function decrement({
payload,
projectId,
}: {
payload: DecrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new Error('Not found');
async function handleIncrement(
payload: IIncrementPayload,
context: TrackContext
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1);
}
async function handleDecrement(
payload: IDecrementPayload,
context: TrackContext
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, -1);
}
async function handleReplay(
payload: IReplayPayload,
context: TrackContext
): Promise<void> {
if (!context.sessionId) {
throw new HttpError('Session ID is required for replay', { status: 400 });
}
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
);
const row = {
project_id: context.projectId,
session_id: context.sessionId,
chunk_index: payload.chunk_index,
started_at: payload.started_at,
ended_at: payload.ended_at,
events_count: payload.events_count,
is_full_snapshot: payload.is_full_snapshot,
payload: payload.payload,
};
await replayBuffer.add(row);
}
if (Number.isNaN(parsed)) {
throw new Error('Not number');
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;
}
profile.properties = assocPath(
property.split('.'),
parsed - (value || 1),
profile.properties,
);
await upsertProfile({
id: profile.id,
projectId,
properties: profile.properties,
isExternal: true,
id: String(profileId),
projectId: context.projectId,
isExternal: !!payload.profileId,
groups: payload.groupIds,
});
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
reply: FastifyReply
) {
// Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body);
if (!validationResult.success) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Validation failed',
errors: validationResult.error.errors,
});
}
const validatedBody = validationResult.data;
// Handle alias (not supported)
if (validatedBody.type === 'alias') {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
// Build request context
const context = await buildContext(request, validatedBody);
// Dispatch to appropriate handler
switch (validatedBody.type) {
case 'track':
await handleTrack(validatedBody.payload, context);
break;
case 'identify':
await handleIdentify(validatedBody.payload, context);
break;
case 'increment':
await handleIncrement(validatedBody.payload, context);
break;
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
case 'replay':
await handleReplay(validatedBody.payload, context);
break;
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,
error: 'Bad Request',
message: 'Invalid type',
});
}
reply.status(200).send({
deviceId: context.deviceId,
sessionId: context.sessionId,
});
}
export async function fetchDeviceId(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
const salts = await getSalts();
const projectId = request.client?.projectId;
@@ -419,20 +470,31 @@ export async function fetchDeviceId(
try {
const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data'
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data'
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = JSON.parse(res?.[0]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId,
message: 'current session exists for this device id',
});
}
if (res?.[1]?.[1]) {
const data = JSON.parse(res?.[1]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: previousDeviceId,
sessionId,
message: 'previous session exists for this device id',
});
}
@@ -442,6 +504,7 @@ export async function fetchDeviceId(
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId: '',
message: 'No session exists for this device id',
});
}

View File

@@ -1,18 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { dirname } from 'node:path';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
import {
sendSlackNotification,
slackInstaller,
} from '@openpanel/integrations/src/slack';
import {
PolarWebhookVerificationError,
getProduct,
PolarWebhookVerificationError,
validatePolarEvent,
} from '@openpanel/payments';
import { publishEvent } from '@openpanel/redis';
@@ -34,7 +34,7 @@ export async function slackWebhook(
request: FastifyRequest<{
Querystring: unknown;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const parsedParams = paramsSchema.safeParse(request.query);
@@ -45,10 +45,10 @@ export async function slackWebhook(
const veryfiedState = await slackInstaller.stateStore?.verifyStateParam(
new Date(),
parsedParams.data.state,
parsedParams.data.state
);
const parsedMetadata = metadataSchema.safeParse(
JSON.parse(veryfiedState?.metadata ?? '{}'),
JSON.parse(veryfiedState?.metadata ?? '{}')
);
if (!parsedMetadata.success) {
@@ -75,7 +75,7 @@ export async function slackWebhook(
zod: parsedJson,
json,
},
'Failed to parse slack auth response',
'Failed to parse slack auth response'
);
const html = fs.readFileSync(path.join(__dirname, 'error.html'), 'utf8');
return reply.status(500).header('Content-Type', 'text/html').send(html);
@@ -104,7 +104,7 @@ export async function slackWebhook(
});
return reply.redirect(
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`,
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`
);
} catch (err) {
request.log.error(err);
@@ -128,13 +128,13 @@ export async function polarWebhook(
request: FastifyRequest<{
Querystring: unknown;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
try {
const event = validatePolarEvent(
request.rawBody!,
request.headers as Record<string, string>,
process.env.POLAR_WEBHOOK_SECRET ?? '',
process.env.POLAR_WEBHOOK_SECRET ?? ''
);
switch (event.type) {
@@ -169,6 +169,11 @@ export async function polarWebhook(
.parse(event.data.metadata);
const product = await getProduct(event.data.productId);
const organization = await db.organization.findUniqueOrThrow({
where: {
id: metadata.organizationId,
},
});
const eventsLimit = product.metadata?.eventsLimit;
const subscriptionPeriodEventsLimit =
typeof eventsLimit === 'number' ? eventsLimit : undefined;
@@ -186,7 +191,9 @@ export async function polarWebhook(
where: {
subscriptionCustomerId: event.data.customer.id,
subscriptionId: event.data.id,
subscriptionStatus: 'active',
subscriptionStatus: {
in: ['active', 'past_due', 'unpaid'],
},
},
});
@@ -216,6 +223,13 @@ export async function polarWebhook(
subscriptionCreatedByUserId: metadata.userId,
subscriptionInterval: event.data.recurringInterval,
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCountExceededAt:
subscriptionPeriodEventsLimit &&
organization.subscriptionPeriodEventsCountExceededAt &&
organization.subscriptionPeriodEventsLimit <
subscriptionPeriodEventsLimit
? null
: undefined,
},
});

View File

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

View File

@@ -1,23 +1,28 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isDuplicatedEvent } from '@/utils/deduplicate';
export async function duplicateHook(
req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload;
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const ip = req.clientIp;
const origin = req.headers.origin;
const clientId = req.headers['openpanel-client-id'];
const shouldCheck = ip && origin && clientId;
const body = req?.body;
const isTrackPayload = getIsTrackPayload(req);
const isReplay = isTrackPayload && req.body.type === 'replay';
const shouldCheck = ip && origin && clientId && !isReplay;
const isDuplicate = shouldCheck
? await isDuplicatedEvent({
ip,
origin,
payload: req.body,
payload: body,
projectId: clientId as string,
})
: false;
@@ -26,3 +31,25 @@ export async function duplicateHook(
return reply.status(200).send('Duplicate event');
}
}
function getIsTrackPayload(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>
): req is FastifyRequest<{
Body: ITrackHandlerPayload;
}> {
if (req.method !== 'POST') {
return false;
}
if (!req.body) {
return false;
}
if (typeof req.body !== 'object' || Array.isArray(req.body)) {
return false;
}
return 'type' in req.body;
}

View File

@@ -1,22 +1,19 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db';
import type { TrackHandlerPayload } from '@openpanel/sdk';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
type DeprecatedEventPayload = {
name: string;
properties: Record<string, unknown>;
timestamp: string;
};
import { isBot } from '@/bots';
export async function isBotHook(
req: FastifyRequest<{
Body: TrackHandlerPayload | DeprecatedEventPayload;
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) {
@@ -46,6 +43,6 @@ export async function isBotHook(
}
}
return reply.status(202).send('OK');
return reply.status(202).send({ bot });
}
}

View File

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

View File

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

View File

@@ -1,27 +1,28 @@
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
process.env.TZ = 'UTC';
import compress from '@fastify/compress';
import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import {
decodeSessionToken,
EMPTY_SESSION,
type SessionValidationResult,
validateSessionToken,
} from '@openpanel/auth';
import { generateId } from '@openpanel/common';
import {
type IServiceClientWithProject,
runWithAlsSession,
} from '@openpanel/db';
import { getCache, getRedisPub } from '@openpanel/redis';
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import {
EMPTY_SESSION,
type SessionValidationResult,
decodeSessionToken,
validateSessionToken,
} from '@openpanel/auth';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import sourceMapSupport from 'source-map-support';
import {
healthcheck,
@@ -35,9 +36,12 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
import logsRouter from './routes/logs.router';
import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
@@ -49,8 +53,6 @@ import { logger } from './utils/logger';
sourceMapSupport.install();
process.env.TZ = 'UTC';
declare module 'fastify' {
interface FastifyRequest {
client: IServiceClientWithProject | null;
@@ -62,13 +64,16 @@ declare module 'fastify' {
}
const port = Number.parseInt(process.env.API_PORT || '3000', 10);
const host =
process.env.API_HOST ||
(process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost');
const startServer = async () => {
logger.info('Starting server');
try {
const fastify = Fastify({
maxParamLength: 15_000,
bodyLimit: 1048576 * 500, // 500MB
bodyLimit: 1_048_576 * 500, // 500MB
loggerInstance: logger as unknown as FastifyBaseLogger,
disableRequestLogging: true,
genReqId: (req) =>
@@ -80,7 +85,7 @@ const startServer = async () => {
fastify.register(cors, () => {
return (
req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void,
callback: (error: Error | null, options: FastifyCorsOptions) => void
) => {
// TODO: set prefix on dashboard routes
const corsPaths = [
@@ -93,7 +98,7 @@ const startServer = async () => {
];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path),
req.url.startsWith(path)
);
if (isPrivatePath) {
@@ -114,6 +119,7 @@ const startServer = async () => {
return callback(null, {
origin: '*',
maxAge: 86_400 * 7, // cache preflight for 7 days
});
};
});
@@ -143,12 +149,21 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const sessionId = decodeSessionToken(req.cookies.session);
const sessionId = decodeSessionToken(req.cookies?.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
validateSessionToken(req.cookies.session)
);
req.session = session;
} catch (e) {
} catch {
req.session = EMPTY_SESSION;
}
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null)
);
req.session = session;
} catch {
req.session = EMPTY_SESSION;
}
} else {
@@ -160,7 +175,7 @@ const startServer = async () => {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext: createContext,
createContext,
onError(ctx) {
if (
ctx.error.code === 'UNAUTHORIZED' &&
@@ -181,8 +196,10 @@ 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' });
instance.register(logsRouter, { prefix: '/logs' });
});
// Public API
@@ -194,6 +211,7 @@ const startServer = async () => {
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
instance.register(manageRouter, { prefix: '/manage' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
// New Kubernetes-style health endpoints
@@ -203,39 +221,50 @@ const startServer = async () => {
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
}),
})
);
});
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
fastify.setErrorHandler((error, request, reply) => {
if (error instanceof HttpError) {
request.log.error(`${error.message}`, error);
if (process.env.NODE_ENV === 'production' && error.status === 500) {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} else {
reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
} else if (error.statusCode === 429) {
reply.status(429).send({
if (error.statusCode === 429) {
return reply.status(429).send({
status: 429,
error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.',
});
} else if (error.statusCode === 400) {
reply.status(400).send({
status: 400,
error,
message: 'The request was invalid.',
});
} else {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
}
if (error instanceof HttpError) {
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('internal server error', { error });
}
if (process.env.NODE_ENV === 'production' && error.status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('request error', { error });
}
const status = error?.statusCode ?? 500;
if (process.env.NODE_ENV === 'production' && status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(status).send({
status,
error,
message: error.message,
});
});
if (process.env.NODE_ENV === 'production') {
@@ -252,10 +281,7 @@ const startServer = async () => {
});
}
await fastify.listen({
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost',
port,
});
await fastify.listen({ host, port });
try {
// Notify when keys expires
@@ -263,7 +289,7 @@ const startServer = async () => {
} catch (error) {
logger.warn('Failed to set redis notify-keyspace-events', error);
logger.warn(
'If you use a managed Redis service, you may need to set this manually.',
'If you use a managed Redis service, you may need to set this manually.'
);
logger.warn('Otherwise some functions may not work as expected.');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
import * as controller from '@/controllers/manage.controller';
import { validateManageRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
const manageRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({
fastify,
max: 20,
timeWindow: '10 seconds',
});
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
try {
const client = await validateManageRequest(req.headers);
req.client = client;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'Client ID seems to be malformed',
});
}
if (e instanceof Error) {
return reply
.status(401)
.send({ error: 'Unauthorized', message: e.message });
}
return reply
.status(401)
.send({ error: 'Unauthorized', message: 'Unexpected error' });
}
});
// Projects routes
fastify.route({
method: 'GET',
url: '/projects',
handler: controller.listProjects,
});
fastify.route({
method: 'GET',
url: '/projects/:id',
handler: controller.getProject,
});
fastify.route({
method: 'POST',
url: '/projects',
handler: controller.createProject,
});
fastify.route({
method: 'PATCH',
url: '/projects/:id',
handler: controller.updateProject,
});
fastify.route({
method: 'DELETE',
url: '/projects/:id',
handler: controller.deleteProject,
});
// Clients routes
fastify.route({
method: 'GET',
url: '/clients',
handler: controller.listClients,
});
fastify.route({
method: 'GET',
url: '/clients/:id',
handler: controller.getClient,
});
fastify.route({
method: 'POST',
url: '/clients',
handler: controller.createClient,
});
fastify.route({
method: 'PATCH',
url: '/clients/:id',
handler: controller.updateClient,
});
fastify.route({
method: 'DELETE',
url: '/clients/:id',
handler: controller.deleteClient,
});
// References routes
fastify.route({
method: 'GET',
url: '/references',
handler: controller.listReferences,
});
fastify.route({
method: 'GET',
url: '/references/:id',
handler: controller.getReference,
});
fastify.route({
method: 'POST',
url: '/references',
handler: controller.createReference,
});
fastify.route({
method: 'PATCH',
url: '/references/:id',
handler: controller.updateReference,
});
fastify.route({
method: 'DELETE',
url: '/references/:id',
handler: controller.deleteReference,
});
};
export default manageRouter;

View File

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

View File

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

View File

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

View File

@@ -1,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,23 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'POST',
url: '/',
handler: handler,
schema: {
body: {
type: 'object',
required: ['type', 'payload'],
properties: {
type: {
type: 'string',
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
},
payload: {
type: 'object',
additionalProperties: true,
},
},
},
},
handler,
});
fastify.route({
@@ -42,6 +25,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
type: 'object',
properties: {
deviceId: { type: 'string' },
sessionId: { type: 'string' },
message: { type: 'string', optional: true },
},
},

View File

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

View File

@@ -1,23 +1,18 @@
import { chartTypes } from '@openpanel/constants';
import type { IClickhouseSession } from '@openpanel/db';
import {
ch,
clix,
type IClickhouseEvent,
type IClickhouseProfile,
TABLE_NAMES,
ch,
clix,
} from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import { zChartInputAI } from '@openpanel/validation';
import { zReportInput } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
export function getReport({
projectId,
}: {
projectId: string;
}) {
export function getReport({ projectId }: { projectId: string }) {
return tool({
description: `Generate a report (a chart) for
- ${chartTypes.area}
@@ -27,7 +22,10 @@ export function getReport({
- ${chartTypes.metric}
- ${chartTypes.bar}
`,
parameters: zChartInputAI,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
execute: async (report) => {
return {
type: 'report',
@@ -64,15 +62,14 @@ export function getReport({
},
});
}
export function getConversionReport({
projectId,
}: {
projectId: string;
}) {
export function getConversionReport({ projectId }: { projectId: string }) {
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
parameters: zChartInputAI,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
execute: async (report) => {
return {
type: 'report',
@@ -86,15 +83,14 @@ export function getConversionReport({
},
});
}
export function getFunnelReport({
projectId,
}: {
projectId: string;
}) {
export function getFunnelReport({ projectId }: { projectId: string }) {
return tool({
description:
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
parameters: zChartInputAI,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
execute: async (report) => {
return {
type: 'report',
@@ -109,11 +105,7 @@ export function getFunnelReport({
});
}
export function getProfiles({
projectId,
}: {
projectId: string;
}) {
export function getProfiles({ projectId }: { projectId: string }) {
return tool({
description: 'Get profiles',
parameters: z.object({
@@ -179,11 +171,7 @@ export function getProfiles({
});
}
export function getProfile({
projectId,
}: {
projectId: string;
}) {
export function getProfile({ projectId }: { projectId: string }) {
return tool({
description: 'Get a specific profile',
parameters: z.object({
@@ -267,11 +255,7 @@ export function getProfile({
});
}
export function getEvents({
projectId,
}: {
projectId: string;
}) {
export function getEvents({ projectId }: { projectId: string }) {
return tool({
description: 'Get events for a project or specific profile',
parameters: z.object({
@@ -360,11 +344,7 @@ export function getEvents({
});
}
export function getSessions({
projectId,
}: {
projectId: string;
}) {
export function getSessions({ projectId }: { projectId: string }) {
return tool({
description: 'Get sessions for a project or specific profile',
parameters: z.object({
@@ -449,11 +429,7 @@ export function getSessions({
});
}
export function getAllEventNames({
projectId,
}: {
projectId: string;
}) {
export function getAllEventNames({ projectId }: { projectId: string }) {
return tool({
description: 'Get the top 50 event names in a comma separated list',
parameters: z.object({}),

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,181 @@
import crypto from 'node:crypto';
import { generateDeviceId } from '@openpanel/common/server';
import { getSafeJson } from '@openpanel/json';
import type {
EventsQueuePayloadCreateSessionEnd,
EventsQueuePayloadIncomingEvent,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { pick } from 'ramda';
export async function getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId,
}: {
projectId: string;
ip: string;
ua: string | undefined;
salts: { current: string; previous: string };
overrideDeviceId?: string;
}) {
if (overrideDeviceId) {
return { deviceId: overrideDeviceId, sessionId: '' };
}
if (!ua) {
return { deviceId: '', sessionId: '' };
}
const currentDeviceId = generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
});
const previousDeviceId = generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
});
return await getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
});
}
interface DeviceIdResult {
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
}
async function getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
}: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
}): Promise<DeviceIdResult> {
try {
const multi = getRedisCache().multi();
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data'
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data'
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[0]?.[1] as string) ?? ''
);
if (data) {
return {
deviceId: currentDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
if (res?.[1]?.[1]) {
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[1]?.[1] as string) ?? ''
);
if (data) {
return {
deviceId: previousDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
} catch (error) {
console.error('Error getting session end GET /track/device-id', error);
}
return {
deviceId: currentDeviceId,
sessionId: getSessionId({
projectId,
deviceId: currentDeviceId,
graceMs: 5 * 1000,
windowMs: 1000 * 60 * 30,
}),
};
}
/**
* Deterministic session id for (projectId, deviceId) within a time window,
* with a grace period at the *start* of each window to avoid boundary splits.
*
* - windowMs: 30 minutes by default
* - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket)
* - Output: base64url, 128-bit (16 bytes) truncated from SHA-256
*/
function getSessionId(params: {
projectId: string;
deviceId: string;
eventMs?: number; // use event timestamp; defaults to Date.now()
windowMs?: number; // default 5 min
graceMs?: number; // default 1 min
bytes?: number; // default 16 (128-bit). You can set 24 or 32 for longer ids.
}): string {
const {
projectId,
deviceId,
eventMs = Date.now(),
windowMs = 5 * 60 * 1000,
graceMs = 60 * 1000,
bytes = 16,
} = params;
if (!projectId) {
throw new Error('projectId is required');
}
if (!deviceId) {
throw new Error('deviceId is required');
}
if (windowMs <= 0) {
throw new Error('windowMs must be > 0');
}
if (graceMs < 0 || graceMs >= windowMs) {
throw new Error('graceMs must be >= 0 and < windowMs');
}
if (bytes < 8 || bytes > 32) {
throw new Error('bytes must be between 8 and 32');
}
const bucket = Math.floor(eventMs / windowMs);
const offset = eventMs - bucket * windowMs;
// Grace at the start of the bucket: stick to the previous bucket.
const chosenBucket = offset < graceMs ? bucket - 1 : bucket;
const input = `sess:v1:${projectId}:${deviceId}:${chosenBucket}`;
const digest = crypto.createHash('sha256').update(input).digest();
const truncated = digest.subarray(0, bytes);
// base64url
return truncated
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}

View File

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

View File

@@ -1,7 +1,13 @@
import urlMetadata from 'url-metadata';
function fallbackFavicon(url: string) {
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
try {
const hostname = new URL(url).hostname;
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
} catch {
// If URL parsing fails, use the original string
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
}
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {
@@ -13,7 +19,7 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
(favicon) =>
favicon.rel === 'shortcut icon' ||
favicon.rel === 'icon' ||
favicon.rel === 'apple-touch-icon',
favicon.rel === 'apple-touch-icon'
);
if (match) {
@@ -66,7 +72,9 @@ interface UrlMetaData {
export async function parseUrlMeta(url: string) {
try {
const metadata = (await urlMetadata(url)) as UrlMetaData;
const metadata = (await urlMetadata(url, {
timeout: 500,
})) as UrlMetaData;
const data = transform(metadata, url);
return data;
} catch (err) {

View File

@@ -5,7 +5,8 @@
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"strictNullChecks": true
},
"include": ["."],
"exclude": ["node_modules", "dist"]

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,7 @@
{
"name": "justfuckinguseopenpanel",
"compatibility_date": "2025-12-19",
"assets": {
"directory": "."
}
}

View File

@@ -2,8 +2,6 @@
/node_modules
# generated content
.contentlayer
.content-collections
.source
# test & build

View File

@@ -1,94 +0,0 @@
ARG NODE_VERSION=20.15.1
FROM --platform=linux/amd64 node:${NODE_VERSION}-slim AS base
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG REDIS_URL
ENV REDIS_URL=$REDIS_URL
ARG CLICKHOUSE_URL
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN apt update \
&& apt install -y curl \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
WORKDIR /app
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/public/package.json apps/public/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/redis/package.json packages/redis/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/common/package.json packages/common/package.json
COPY packages/constants/package.json packages/constants/package.json
COPY packages/validation/package.json packages/validation/package.json
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
COPY packages/sdks/_info/package.json packages/sdks/_info/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/public
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps/public apps/public
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/public
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app/apps/public
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/public /app/apps/public
# Apps node_modules
COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules
# Packages
COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/redis /app/packages/redis
COPY --from=build /app/packages/common /app/packages/common
COPY --from=build /app/packages/queue /app/packages/queue
COPY --from=build /app/packages/constants /app/packages/constants
COPY --from=build /app/packages/validation /app/packages/validation
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
COPY --from=build /app/packages/sdks/_info /app/packages/sdks/_info
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
RUN pnpm db:codegen
WORKDIR /app/apps/public
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@@ -15,6 +15,25 @@ yarn dev
Open http://localhost:3000 with your browser to see the result.
## Explore
In the project, you can see:
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
| Route | Description |
| ------------------------- | ------------------------------------------------------ |
| `app/(home)` | The route group for your landing page and other pages. |
| `app/docs` | The documentation layout and pages. |
| `app/api/search/route.ts` | The Route Handler for search. |
### Fumadocs MDX
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
## Learn More
To learn more about Next.js and Fumadocs, take a look at the following
@@ -23,4 +42,4 @@ resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs

View File

@@ -1,203 +0,0 @@
import { url, getAuthor } from '@/app/layout.config';
import { SingleSwirl } from '@/components/Swirls';
import { ArticleCard } from '@/components/article-card';
import { Logo } from '@/components/logo';
import { SectionHeader } from '@/components/section';
import { Toc } from '@/components/toc';
import { Button } from '@/components/ui/button';
import { articleSource } from '@/lib/source';
import { ArrowLeftIcon } from 'lucide-react';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import Script from 'next/script';
export async function generateMetadata({
params,
}: {
params: Promise<{ articleSlug: string }>;
}): Promise<Metadata> {
const { articleSlug } = await params;
const article = await articleSource.getPage([articleSlug]);
const author = getAuthor(article?.data.team);
if (!article) {
return {
title: 'Article Not Found',
};
}
return {
title: article.data.title,
description: article.data.description,
authors: [{ name: author.name }],
alternates: {
canonical: url(article.url),
},
openGraph: {
title: article.data.title,
description: article.data.description,
type: 'article',
publishedTime: article.data.date.toISOString(),
authors: author.name,
images: url(article.data.cover),
url: url(article.url),
},
twitter: {
card: 'summary_large_image',
title: article.data.title,
description: article.data.description,
images: url(article.data.cover),
},
};
}
export default async function Page({
params,
}: {
params: Promise<{ articleSlug: string }>;
}) {
const { articleSlug } = await params;
const article = await articleSource.getPage([articleSlug]);
const Body = article?.data.body;
const author = getAuthor(article?.data.team);
const goBackUrl = '/articles';
const relatedArticles = (await articleSource.getPages())
.filter(
(item) =>
item.data.tag === article?.data.tag && item.url !== article?.url,
)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
if (!Body) {
return notFound();
}
// Create the JSON-LD data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article?.data.title,
datePublished: article?.data.date.toISOString(),
author: {
'@type': 'Person',
name: author.name,
},
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url(article.url),
},
image: {
'@type': 'ImageObject',
url: url(article.data.cover),
},
};
return (
<div>
<Script
strategy="beforeInteractive"
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="container max-w-5xl col">
<div className="py-16">
<Link
href={goBackUrl}
className="flex items-center gap-2 mb-4 text-muted-foreground"
>
<ArrowLeftIcon className="w-4 h-4" />
<span>Back to all articles</span>
</Link>
<div className="flex-col-reverse col md:row gap-8">
<div className="col flex-1">
<h1 className="text-5xl font-bold leading-tight">
{article?.data.title}
</h1>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
{author.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
alt={author.name}
width={48}
height={48}
/>
) : (
<Logo className="w-6 h-6 fill-white" />
)}
</div>
<div className="col">
<p className="font-medium">{author.name}</p>
<p className="text-muted-foreground text-sm">
{article?.data.date.toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="relative">
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
<div className="min-w-0">
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
<Body />
</div>
</div>
<aside className="pl-12 pb-12 gap-8 col">
<Toc toc={article?.data.toc} />
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl py-16">
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0 size-[300px]" />
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50 size-[300px]" />
<div className="container center-center col">
<SectionHeader
className="mb-8"
title="Try it"
description="Give it a spin for free. No credit card required."
/>
<Button size="lg" variant="secondary" asChild>
<Link href="https://dashboard.openpanel.dev/onboarding">
Get started today!
</Link>
</Button>
</div>
</section>
</aside>
</div>
{relatedArticles.length > 0 && (
<div className="my-16">
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{relatedArticles.map((item) => (
<ArticleCard
key={item.url}
url={item.url}
title={item.data.title}
tag={item.data.tag}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</div>
</div>
)}
</div>
</article>
</div>
);
}

View File

@@ -1,55 +0,0 @@
import { url } from '@/app/layout.config';
import { ArticleCard } from '@/components/article-card';
import { articleSource } from '@/lib/source';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
const title = 'Articles';
const description = 'Read our latest articles';
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: url('/articles'),
},
openGraph: {
title,
description,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description,
},
};
export default async function Page() {
const articles = (await articleSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
return (
<div>
<div className="container col">
<div className="py-16">
<h1 className="text-center text-7xl font-bold">Articles</h1>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
{articles.map((item) => (
<ArticleCard
key={item.url}
url={item.url}
title={item.data.title}
tag={item.data.tag}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { Footer } from '@/components/footer';
import { HeroContainer } from '@/components/hero';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
export default function Layout({
children,
}: {
children: ReactNode;
}): React.ReactElement {
return (
<>
<Navbar />
<main className="overflow-hidden">
<HeroContainer className="h-screen pointer-events-none" />
<div className="absolute h-screen inset-0 radial-gradient-dot-pages select-none pointer-events-none" />
<div className="-mt-[calc(100vh-100px)] relative min-h-[500px] pb-12 -mb-24">
{children}
</div>
</main>
<Footer />
</>
);
}

View File

@@ -1,288 +0,0 @@
import { url } from '@/app/layout.config';
import { HeroContainer } from '@/components/hero';
import { Section, SectionHeader } from '@/components/section';
import { Faq } from '@/components/sections/faq';
import { SupporterPerks } from '@/components/sections/supporter-perks';
import { Testimonials } from '@/components/sections/testimonials';
import { Tag } from '@/components/tag';
import { Button } from '@/components/ui/button';
import {
ArrowDownIcon,
HeartHandshakeIcon,
SparklesIcon,
ZapIcon,
} from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
export const metadata: Metadata = {
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
alternates: {
canonical: url('/supporter'),
},
openGraph: {
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
type: 'website',
url: url('/supporter'),
},
twitter: {
card: 'summary_large_image',
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
},
};
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Become a Supporter',
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url('/supporter'),
},
};
export default function SupporterPage() {
return (
<div>
<Script
id="supporter-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<div className="container relative z-10 col sm:py-44 max-sm:pt-32">
<div className="col gap-8 text-center">
<div className="col gap-4">
<Tag className="self-center">
<HeartHandshakeIcon className="size-4 text-rose-600" />
Support Open-Source Analytics
</Tag>
<h1 className="text-4xl md:text-5xl font-extrabold leading-[1.1]">
Help us build the future of{' '}
<span className="text-primary">open analytics</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Your support accelerates development, funds infrastructure, and
helps us build features faster. Plus, you get exclusive perks
and early access to everything we ship.
</p>
</div>
<div className="col gap-4 justify-center items-center">
<Button size="lg" asChild>
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
Become a Supporter
<SparklesIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Starting at $20/month Cancel anytime
</p>
</div>
</div>
</div>
</HeroContainer>
<div className="container max-w-7xl">
{/* Main Content with Sidebar */}
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
{/* Main Content */}
<div className="col gap-12">
{/* Why Support Section */}
<section className="col gap-6">
<h2 className="text-3xl font-bold">Why your support matters</h2>
<div className="col gap-6 text-muted-foreground">
<p className="text-lg">
We're not a big corporation just a small team passionate
about building something useful for developers. OpenPanel
started because we believed analytics tools shouldn't be
complicated or locked behind expensive enterprise
subscriptions.
</p>
<p>When you become a supporter, you're directly funding:</p>
<ul className="col gap-3 list-none pl-0">
<li className="flex items-start gap-3">
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<strong className="text-foreground">
Active Development
</strong>
<p className="text-sm mt-1">
More time fixing bugs, adding features, and improving
documentation
</p>
</div>
</li>
<li className="flex items-start gap-3">
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<strong className="text-foreground">
Infrastructure
</strong>
<p className="text-sm mt-1">
Keeping servers running, CI/CD pipelines, and
development tools
</p>
</div>
</li>
<li className="flex items-start gap-3">
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<strong className="text-foreground">Independence</strong>
<p className="text-sm mt-1">
Staying focused on what matters: building a tool
developers actually want
</p>
</div>
</li>
</ul>
<p>
No corporate speak, no fancy promises just honest work on
making OpenPanel better for everyone. Every contribution, no
matter the size, helps us stay independent and focused on what
matters.
</p>
</div>
</section>
{/* What You Get Section */}
<section className="col gap-6">
<h2 className="text-3xl font-bold">
What you get as a supporter
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
🚀 Latest Docker Images
</h3>
<p className="text-sm text-muted-foreground mb-3">
Get bleeding-edge builds on every commit. Access new
features weeks before public release.
</p>
<Link
href="/docs/self-hosting/supporter-access-latest-docker-images"
className="text-sm text-primary hover:underline"
>
Learn more →
</Link>
</div>
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
💬 Prioritized Support
</h3>
<p className="text-sm text-muted-foreground mb-3">
Get help faster with priority support in our Discord
community. Your questions get answered first.
</p>
</div>
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
✨ Feature Requests
</h3>
<p className="text-sm text-muted-foreground mb-3">
Your ideas and feature requests get prioritized in our
roadmap. Shape the future of OpenPanel.
</p>
</div>
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
⭐ Exclusive Discord Role
</h3>
<p className="text-sm text-muted-foreground mb-3">
Special badge and recognition in our community. Show your
support with pride.
</p>
</div>
</div>
</section>
{/* Impact Section */}
<section className="p-8 rounded-xl border bg-gradient-to-br from-primary/5 to-primary/10">
<h2 className="text-2xl font-bold mb-4">Your impact</h2>
<p className="text-muted-foreground mb-6">
Every dollar you contribute goes directly into development,
infrastructure, and making OpenPanel better. Here's what your
support enables:
</p>
<div className="grid md:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-primary mb-2">
100%
</div>
<div className="text-sm text-muted-foreground">
Open Source
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-primary mb-2">
24/7
</div>
<div className="text-sm text-muted-foreground">
Active Development
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-primary mb-2"></div>
<div className="text-sm text-muted-foreground">
Self-Hostable
</div>
</div>
</div>
</section>
</div>
{/* Sidebar */}
<aside className="lg:block hidden">
<SupporterPerks />
</aside>
</div>
{/* Mobile Perks */}
<div className="lg:hidden mb-16">
<SupporterPerks />
</div>
{/* CTA Section */}
<Section className="container my-0 py-20">
<SectionHeader
tag={
<Tag>
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
Starting at $20/month
</Tag>
}
title="Ready to support OpenPanel?"
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
/>
<div className="center-center">
<Button size="lg" asChild>
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
Become a Supporter Now
<HeartHandshakeIcon className="size-4" />
</Link>
</Button>
</div>
</Section>
</div>
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
<Testimonials />
<Faq />
</div>
</div>
);
}

View File

@@ -1,7 +0,0 @@
import {
createNextRouteHandler,
createScriptHandler,
} from '@openpanel/nextjs/server';
export const POST = createNextRouteHandler();
export const GET = createScriptHandler();

View File

@@ -1,4 +0,0 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);

View File

@@ -1,67 +0,0 @@
import { url, siteName } from '@/app/layout.config';
import { source } from '@/lib/source';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
alternates: {
canonical: url(page.url),
},
icons: {
apple: '/apple-touch-icon.png',
icon: '/favicon.ico',
},
manifest: '/site.webmanifest',
openGraph: {
url: url(page.url),
type: 'article',
images: [
{
url: url('/ogimage.jpg'),
width: 1200,
height: 630,
alt: siteName,
},
],
},
};
}

View File

@@ -1,12 +0,0 @@
import { baseOptions } from '@/app/layout.config';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

View File

@@ -1,224 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--green: 156 71% 67%;
--red: 351 89% 72%;
--background: 0 0% 98%;
--background-light: 0 0% 100%;
--background-dark: 0 0% 96%;
--foreground: 0 0% 9%;
--foreground-dark: 0 0% 7.5%;
--foreground-light: 0 0% 11%;
--card: 0 0% 98%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 98%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 9%;
--background-dark: 0 0% 7.5%;
--background-light: 0 0% 11%;
--foreground: 0 0% 98%;
--foreground-light: 0 0% 100%;
--foreground-dark: 0 0% 96%;
--card: 0 0% 9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply !bg-[hsl(var(--background))] text-foreground font-sans text-base antialiased flex flex-col min-h-screen;
}
}
@layer components {
.container {
@apply max-w-6xl mx-auto px-6 md:px-10 lg:px-14 w-full;
}
.pulled {
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
}
.row {
@apply flex flex-row;
}
.col {
@apply flex flex-col;
}
.center-center {
@apply flex items-center justify-center text-center;
}
}
strong {
@apply font-semibold;
}
.radial-gradient {
background: #BECCDF;
background: radial-gradient(at bottom, hsl(var(--background-light)), hsl(var(--background)));
}
.radial-gradient-dot-1 {
background: #BECCDF;
background: radial-gradient(at 50% 20%, hsl(var(--background-light)), transparent);
}
.radial-gradient-dot-pages {
background: #BECCDF;
background: radial-gradient(at 50% 50%, hsl(var(--background)), hsl(var(--background)/0.2));
}
.animated-iframe-gradient {
position: relative;
overflow: hidden;
background: transparent;
}
.animated-iframe-gradient:before {
content: '';
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1600px;
height: 1600px;
background: linear-gradient(250deg, hsl(var(--foreground)/0.9), transparent);
animation: GradientRotate 8s linear infinite;
}
@keyframes GradientRotate {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
.line-before {
position: relative;
padding: 16px;
}
.line-before:before {
content: '';
display: block;
position: absolute;
top: calc(4px*-32);
bottom: calc(4px*-32);
left: 0;
width: 1px;
background: hsl(var(--foreground)/0.1);
}
.line-after {
position: relative;
padding: 16px;
}
.line-after:after {
content: '';
display: block;
position: absolute;
top: calc(4px*-32);
bottom: calc(4px*-32);
right: 0;
width: 1px;
background: hsl(var(--foreground)/0.1);
}
.animate-fade-up {
animation: animateFadeUp 0.5s ease-in-out;
animation-fill-mode: both;
}
@keyframes animateFadeUp {
0% { transform: translateY(0.5rem); scale: 0.95; }
100% { transform: translateY(0); scale: 1; }
}
.animate-fade-down {
animation: animateFadeDown 0.5s ease-in-out;
animation-fill-mode: both;
}
@keyframes animateFadeDown {
0% { transform: translateY(-1rem); }
100% { transform: translateY(0); }
}
/* Docs */
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
font-size: inherit !important;
}
.prose pre {
background: hsl(var(--background-dark));
border: 1px solid hsl(var(--background-light));
padding: 10px 15px;
border-radius: 10px;
font-size: 12px;
}
.prose pre code {
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
border: none;
}
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] {
max-height: 400px;
}
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] pre{
max-height: none;
}

View File

@@ -1,66 +0,0 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
/**
* Shared layout configurations
*
* you can configure layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export const siteName = 'OpenPanel';
export const baseUrl = 'https://openpanel.dev';
export const url = (path: string) => `${baseUrl}${path}`;
export const baseOptions: BaseLayoutProps = {
nav: {
title: siteName,
},
links: [
{
type: 'main',
text: 'Home',
url: '/',
active: 'nested-url',
},
{
type: 'main',
text: 'Pricing',
url: '/pricing',
active: 'nested-url',
},
{
type: 'main',
text: 'Supporter',
url: '/supporter',
active: 'nested-url',
},
{
type: 'main',
text: 'Documentation',
url: '/docs',
active: 'nested-url',
},
{
type: 'main',
text: 'Articles',
url: '/articles',
active: 'nested-url',
},
],
} as const;
export const authors = [
{
name: 'OpenPanel Team',
url: 'https://openpanel.com',
},
{
name: 'Carl-Gerhard Lindesvärd',
url: 'https://openpanel.com',
image: '/twitter-carl.jpg',
},
];
export const getAuthor = (author?: string) => {
return authors.find((a) => a.name === author)!;
};

View File

@@ -1,75 +0,0 @@
import { RootProvider } from 'fumadocs-ui/provider';
import type { ReactNode } from 'react';
import './global.css';
import { TooltipProvider } from '@/components/ui/tooltip';
import { OpenPanelComponent } from '@openpanel/nextjs';
import { cn } from 'fumadocs-ui/components/api';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import { url, baseUrl, siteName } from './layout.config';
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
userScalable: true,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
],
};
const description = `${siteName} is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.`;
export const metadata: Metadata = {
title: {
default: siteName,
template: `%s | ${siteName}`,
},
description,
alternates: {
canonical: baseUrl,
},
icons: {
apple: '/apple-touch-icon.png',
icon: '/favicon.ico',
},
manifest: '/site.webmanifest',
openGraph: {
title: siteName,
description,
siteName: siteName,
url: baseUrl,
type: 'website',
images: [
{
url: url('/ogimage.jpg'),
width: 1200,
height: 630,
alt: siteName,
},
],
},
};
export default async function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className={cn(GeistSans.variable, GeistMono.variable)}>
<RootProvider>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
<OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
trackAttributes
trackScreenViews
trackOutgoingLinks
/>
</body>
</html>
);
}

View File

@@ -1,22 +0,0 @@
import type { MetadataRoute } from 'next';
import { metadata } from './layout';
export default function manifest(): MetadataRoute.Manifest {
return {
name: metadata.title as string,
short_name: 'Openpanel.dev',
description: metadata.description!,
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: '/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
};
}

View File

@@ -1,30 +0,0 @@
import { baseOptions } from '@/app/layout.config';
import { Footer } from '@/components/footer';
import { HeroContainer } from '@/components/hero';
import Navbar from '@/components/navbar';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import type { ReactNode } from 'react';
export default function NotFound({
children,
}: {
children: ReactNode;
}): React.ReactElement {
return (
<div>
<Navbar />
<HeroContainer className="h-screen center-center">
<div className="relative z-10 col gap-2">
<div className="text-[150px] font-mono font-bold opacity-5 -mb-4">
404
</div>
<h1 className="text-6xl font-bold">Not Found</h1>
<p className="text-xl text-muted-foreground">
Awkward, we couldn&apos;t find what you were looking for.
</p>
</div>
</HeroContainer>
<Footer />
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { Footer } from '@/components/footer';
import { Hero } from '@/components/hero';
import Navbar from '@/components/navbar';
import { Faq } from '@/components/sections/faq';
import { Features } from '@/components/sections/features';
import { Pricing } from '@/components/sections/pricing';
import { Sdks } from '@/components/sections/sdks';
import { Stats, StatsPure } from '@/components/sections/stats';
import { Testimonials } from '@/components/sections/testimonials';
import { WhyOpenPanel } from '@/components/why-openpanel';
import type { Metadata } from 'next';
import { Suspense } from 'react';
export const metadata: Metadata = {
title: 'OpenPanel | An open-source alternative to Mixpanel',
};
// export const experimental_ppr = true;
export default function HomePage() {
return (
<>
<Navbar />
<main>
<Hero />
<WhyOpenPanel />
<Features />
<Testimonials />
<Faq />
<Pricing />
<Sdks />
</main>
<Footer />
</>
);
}

View File

@@ -1,53 +0,0 @@
import { articleSource, pageSource, source } from '@/lib/source';
import type { MetadataRoute } from 'next';
import { url } from './layout.config';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await articleSource.getPages();
const docs = await source.getPages();
const pages = await pageSource.getPages();
return [
{
url: url('/'),
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
{
url: url('/docs'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: url('/articles'),
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
{
url: url('/supporter'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
...articles.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'yearly' as const,
priority: 0.5,
})),
...docs.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'monthly' as const,
priority: 0.3,
})),
...pages.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'monthly' as const,
priority: 0.3,
})),
];
}

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