Compare commits
108 Commits
feature/pe
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f880b9a697 | ||
|
|
13bd16b207 | ||
|
|
347a01a941 | ||
|
|
ba79ac570c | ||
|
|
ca15717885 | ||
|
|
ba93924d20 | ||
|
|
7b87a57cbe | ||
|
|
39251c8598 | ||
|
|
9a4aa51975 | ||
|
|
f008fb58e5 | ||
|
|
cabfb1f3f0 | ||
|
|
4867260ece | ||
|
|
87c919f700 | ||
|
|
3c085e445d | ||
|
|
6d9e3ce8e5 | ||
|
|
f187065d75 | ||
|
|
d5e4518e32 | ||
|
|
1f088d2208 | ||
|
|
3bd1f99d28 | ||
|
|
9a916f3171 | ||
|
|
34cb186ead | ||
|
|
5f38560373 | ||
|
|
1e4f02fb5e | ||
|
|
3158ebfbda | ||
|
|
d7c6e88adc | ||
|
|
3b61b28290 | ||
|
|
8dfeaa870c | ||
|
|
329f76b7ce | ||
|
|
3b74d8ae36 | ||
|
|
a2a53cf9f7 | ||
|
|
4e7dc16619 | ||
|
|
0f9ac4508a | ||
|
|
c46cda12eb | ||
|
|
546ef6673f | ||
|
|
3b2ed3afb1 | ||
|
|
95846f80e5 | ||
|
|
1f5c648afe | ||
|
|
3d8a3e8997 | ||
|
|
28692d82ae | ||
|
|
4bdbb31180 | ||
|
|
be248717d2 | ||
|
|
56bd1197a6 | ||
|
|
9ccca322e5 | ||
|
|
86a3da869b | ||
|
|
ae383001bc | ||
|
|
9bedd39e48 | ||
|
|
7131e3f461 | ||
|
|
9665a2593f | ||
|
|
c201bea682 | ||
|
|
8312556b38 | ||
|
|
d2b22867b9 | ||
|
|
2b8bcf1ed7 | ||
|
|
e22a5b3237 | ||
|
|
315e4a59a3 | ||
|
|
969c0bc8fe | ||
|
|
4e42689115 | ||
|
|
d3522c51f8 | ||
|
|
abf5353ab3 | ||
|
|
2dda50fc7c | ||
|
|
cbdb3a62c1 | ||
|
|
4b775ff2c5 | ||
|
|
a37e37c28b | ||
|
|
64afd04f7b | ||
|
|
2468dc29ff | ||
|
|
662975dc08 | ||
|
|
0a1564c798 | ||
|
|
56b01ca6d8 | ||
|
|
e5b9865850 | ||
|
|
5576519a2a | ||
|
|
1f74ab99ae | ||
|
|
3ae7d1322e | ||
|
|
e4b919c4da | ||
|
|
50ef4c0d94 | ||
|
|
a7a357eb0f | ||
|
|
7adc2903d2 | ||
|
|
ac4429d6d9 | ||
|
|
e2536774b0 | ||
|
|
a39796a829 | ||
|
|
8b31e4cfba | ||
|
|
ae09748e4e | ||
|
|
4ef0b1afe2 | ||
|
|
620904b4d4 | ||
|
|
d4e3470f7e | ||
|
|
d154e12d66 | ||
|
|
b421474616 | ||
|
|
828c8c4f91 | ||
|
|
6da8267509 | ||
|
|
86903b1937 | ||
|
|
4f9d66693e | ||
|
|
32415d31d9 | ||
|
|
f7055c0ebd | ||
|
|
c9cf0274af | ||
|
|
44184236a8 | ||
|
|
71270b3493 | ||
|
|
00e25ed4b8 | ||
|
|
83e223a496 | ||
|
|
790801b728 | ||
|
|
d61cbf6f2c | ||
|
|
887ed09388 | ||
|
|
8ba714ce81 | ||
|
|
c8e3cf8552 | ||
|
|
18c056f3ea | ||
|
|
1562d49fd6 | ||
|
|
aa8765d627 | ||
|
|
56c74e13ff | ||
|
|
10726bf373 | ||
|
|
dcc0d0df18 | ||
|
|
da59622dce |
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
2
.vscode/settings.json
vendored
@@ -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": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<h1 align="center"><b>Openpanel</b></h1>
|
||||
@@ -98,6 +98,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 +114,4 @@ You can now access the following:
|
||||
- API: https://api.localhost:3333
|
||||
- Bullboard (queue): http://localhost:9999
|
||||
- `pnpm dock:ch` to access clickhouse terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
|
||||
@@ -38,11 +38,9 @@ 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 patches ./patches
|
||||
|
||||
# BUILD
|
||||
@@ -107,7 +105,6 @@ 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
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.10",
|
||||
"@ai-sdk/openai": "^1.3.12",
|
||||
"@fastify/compress": "^8.0.1",
|
||||
"@fastify/compress": "^8.1.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/rate-limit": "^10.2.2",
|
||||
"@fastify/websocket": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@openpanel/auth": "workspace:^",
|
||||
"@openpanel/common": "workspace:*",
|
||||
@@ -29,16 +29,16 @@
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"groupmq": "catalog:",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"ai": "^4.2.10",
|
||||
"fast-json-stable-hash": "^1.0.3",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "1.0.0-next.19",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"sharp": "^0.33.5",
|
||||
@@ -52,7 +52,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@openpanel/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
@@ -65,4 +64,4 @@
|
||||
"tsdown": "0.14.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,23 @@ 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 = /[|^$.*+?(){}\[\]\\]/;
|
||||
|
||||
function transformBots(bots: any[]): any[] {
|
||||
return bots.map((bot) => {
|
||||
const { regex, ...rest } = bot;
|
||||
const hasRegexChars = regexSpecialChars.test(regex);
|
||||
|
||||
if (hasRegexChars) {
|
||||
// Keep as regex
|
||||
return { regex, ...rest };
|
||||
}
|
||||
// Convert to includes
|
||||
return { includes: regex, ...rest };
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get document, or throw exception on error
|
||||
try {
|
||||
@@ -14,6 +31,9 @@ async function main() {
|
||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
).then((res) => res.text());
|
||||
|
||||
const parsedData = yaml.load(data) as any[];
|
||||
const transformedBots = transformBots(parsedData);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/bots/bots.ts'),
|
||||
[
|
||||
@@ -21,11 +41,20 @@ async function main() {
|
||||
'',
|
||||
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
'',
|
||||
`const bots = ${JSON.stringify(yaml.load(data))} as const;`,
|
||||
`const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
|
||||
'export default bots;',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ 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;
|
||||
console.log(` - ${includesCount} simple string matches (includes)`);
|
||||
console.log(` - ${regexCount} regex patterns`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ async function main() {
|
||||
properties: {
|
||||
hash: 'test-hash',
|
||||
'query.utm_source': 'test',
|
||||
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
|
||||
__user_agent: 'Mozilla/5.0 (Test)',
|
||||
},
|
||||
created_at: formatClickhouseDate(eventTime),
|
||||
country: 'US',
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
export function isBot(ua: string) {
|
||||
const res = bots.find((bot) => {
|
||||
if (new RegExp(bot.regex).test(ua)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
// Pre-compile regex patterns at module load time
|
||||
const compiledBots = bots.map((bot) => {
|
||||
if ('regex' in bot) {
|
||||
return {
|
||||
...bot,
|
||||
compiledRegex: new RegExp(bot.regex),
|
||||
};
|
||||
}
|
||||
return bot;
|
||||
});
|
||||
|
||||
return {
|
||||
name: res.name,
|
||||
type: 'category' in res ? res.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
for (const bot of includesBots) {
|
||||
if (ua.includes(bot.includes)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check regex patterns (slower)
|
||||
for (const bot of regexBots) {
|
||||
if (bot.compiledRegex.test(ua)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,17 +2,16 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { eventsGroupQueue } from '@openpanel/queue';
|
||||
import type { PostEventPayload } from '@openpanel/sdk';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
|
||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||
|
||||
export async function postEvent(
|
||||
request: FastifyRequest<{
|
||||
Body: PostEventPayload;
|
||||
Body: DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -21,7 +20,7 @@ export async function postEvent(
|
||||
request.body,
|
||||
);
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -31,33 +30,22 @@ export async function postEvent(
|
||||
}
|
||||
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
@@ -65,7 +53,16 @@ export async function postEvent(
|
||||
? `${projectId}:${request.body?.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
await eventsGroupQueue.add({
|
||||
const jobId = [
|
||||
slug(request.body.name),
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
data: {
|
||||
projectId,
|
||||
@@ -75,11 +72,13 @@ export async function postEvent(
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
reply.status(202).send('ok');
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
getEventsCountCached,
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartEvent, zChartInput } from '@openpanel/validation';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
|
||||
async function getProjectId(
|
||||
@@ -139,7 +139,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zChartInput
|
||||
const chartSchemeFull = zReport
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
interval: true,
|
||||
@@ -151,14 +151,27 @@ 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(
|
||||
@@ -179,9 +192,17 @@ export async function charts(
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
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 +215,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',
|
||||
});
|
||||
|
||||
@@ -96,8 +96,6 @@ export async function getPages(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,8 +168,6 @@ export function getOverviewGeneric(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import superjson from 'superjson';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileByIdCached,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
@@ -92,10 +92,7 @@ export async function wsProjectEvents(
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileByIdCached(
|
||||
event.profileId,
|
||||
event.projectId,
|
||||
);
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import {
|
||||
DEFAULT_HEADER_ORDER,
|
||||
DEFAULT_IP_HEADER_ORDER,
|
||||
getClientIpFromHeaders,
|
||||
} from '@openpanel/common/server/get-client-ip';
|
||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
@@ -118,7 +118,11 @@ async function fetchImage(
|
||||
|
||||
// Check if URL is an ICO file
|
||||
function isIcoFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
|
||||
return (
|
||||
url.toLowerCase().endsWith('.ico') ||
|
||||
contentType === 'image/x-icon' ||
|
||||
contentType === 'image/vnd.microsoft.icon'
|
||||
);
|
||||
}
|
||||
function isSvgFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
||||
@@ -132,7 +136,7 @@ async function processImage(
|
||||
): Promise<Buffer> {
|
||||
// If it's an ICO file, just return it as-is (no conversion needed)
|
||||
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
||||
logger.info('Serving ICO file directly', {
|
||||
logger.debug('Serving ICO file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -140,7 +144,7 @@ async function processImage(
|
||||
}
|
||||
|
||||
if (originalUrl && isSvgFile(originalUrl, contentType)) {
|
||||
logger.info('Serving SVG file directly', {
|
||||
logger.debug('Serving SVG file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -149,7 +153,7 @@ async function processImage(
|
||||
|
||||
// If buffer isnt to big just return it as well
|
||||
if (buffer.length < 5000) {
|
||||
logger.info('Serving image directly without processing', {
|
||||
logger.debug('Serving image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -193,7 +197,7 @@ async function processOgImage(
|
||||
): Promise<Buffer> {
|
||||
// If buffer is small enough, return it as-is
|
||||
if (buffer.length < 10000) {
|
||||
logger.info('Serving OG image directly without processing', {
|
||||
logger.debug('Serving OG image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -239,7 +243,9 @@ export async function getFavicon(
|
||||
try {
|
||||
const url = validateUrl(request.query.url);
|
||||
if (!url) {
|
||||
return createFallbackImage();
|
||||
reply.header('Content-Type', 'image/png');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
return reply.send(createFallbackImage());
|
||||
}
|
||||
|
||||
const cacheKey = createCacheKey(url.toString());
|
||||
@@ -260,21 +266,65 @@ export async function getFavicon(
|
||||
} else {
|
||||
// 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) {
|
||||
// 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) {
|
||||
reply.header('Content-Type', 'image/png');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
return reply.send(createFallbackImage());
|
||||
}
|
||||
|
||||
@@ -285,9 +335,31 @@ export async function getFavicon(
|
||||
contentType,
|
||||
);
|
||||
|
||||
logger.info('Favicon processing result', {
|
||||
originalUrl: url.toString(),
|
||||
originalBufferLength: buffer.length,
|
||||
processedBufferLength: processedBuffer.length,
|
||||
});
|
||||
|
||||
// Determine the correct content type for caching and response
|
||||
const isIco = isIcoFile(imageUrl.toString(), contentType);
|
||||
const responseContentType = isIco ? 'image/x-icon' : contentType;
|
||||
const isSvg = isSvgFile(imageUrl.toString(), contentType);
|
||||
let responseContentType = contentType;
|
||||
|
||||
if (isIco) {
|
||||
responseContentType = 'image/x-icon';
|
||||
} else if (isSvg) {
|
||||
responseContentType = 'image/svg+xml';
|
||||
} else if (
|
||||
processedBuffer.length < 5000 &&
|
||||
buffer.length === processedBuffer.length
|
||||
) {
|
||||
// Image was returned as-is, keep original content type
|
||||
responseContentType = contentType;
|
||||
} else {
|
||||
// Image was processed by Sharp, it's now a PNG
|
||||
responseContentType = 'image/png';
|
||||
}
|
||||
|
||||
// Cache the result with correct content type
|
||||
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
||||
@@ -397,10 +469,10 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||
}
|
||||
|
||||
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const ip = getClientIpFromHeaders(request.headers);
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
const others = await Promise.all(
|
||||
DEFAULT_HEADER_ORDER.map(async (header) => {
|
||||
const ip = getClientIpFromHeaders(request.headers, header);
|
||||
DEFAULT_IP_HEADER_ORDER.map(async (header) => {
|
||||
const { ip } = getClientIpFromHeaders(request.headers, header);
|
||||
return {
|
||||
header,
|
||||
ip,
|
||||
@@ -417,13 +489,14 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
selected: {
|
||||
geo,
|
||||
ip,
|
||||
header,
|
||||
},
|
||||
...others.reduce(
|
||||
(acc, other) => {
|
||||
acc[other.header] = other;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { ip: string; geo: GeoLocation }>,
|
||||
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,61 +1,58 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type {
|
||||
IncrementProfilePayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@openpanel/sdk';
|
||||
DeprecatedIncrementProfilePayload,
|
||||
DeprecatedUpdateProfilePayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export async function updateProfile(
|
||||
request: FastifyRequest<{
|
||||
Body: UpdateProfilePayload;
|
||||
Body: DeprecatedUpdateProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { profileId, properties, ...rest } = request.body;
|
||||
const payload = request.body;
|
||||
const projectId = request.client!.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const uaInfo = parseUserAgent(ua, properties);
|
||||
const ua = request.headers['user-agent'];
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertProfile({
|
||||
id: profileId,
|
||||
...payload,
|
||||
id: payload.profileId,
|
||||
isExternal: true,
|
||||
projectId,
|
||||
properties: {
|
||||
...(properties ?? {}),
|
||||
...(ip ? geo : {}),
|
||||
...uaInfo,
|
||||
...(payload.properties ?? {}),
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
|
||||
reply.status(202).send(profileId);
|
||||
reply.status(202).send(payload.profileId);
|
||||
}
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -65,18 +62,6 @@ export async function incrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
@@ -109,7 +94,7 @@ export async function incrementProfileProperty(
|
||||
|
||||
export async function decrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -119,18 +104,6 @@ export async function decrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, assocPath, pathOr, pick } from 'ramda';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { generateId, slug } 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 { eventsGroupQueue } from '@openpanel/queue';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
TrackPayload,
|
||||
} from '@openpanel/sdk';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type ITrackHandlerPayload,
|
||||
type ITrackPayload,
|
||||
zTrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
return Object.entries(
|
||||
@@ -36,284 +39,224 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
);
|
||||
}
|
||||
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity = path<IdentifyPayload>(
|
||||
['properties', '__identify'],
|
||||
body.payload,
|
||||
);
|
||||
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
||||
if (body.type === 'track') {
|
||||
const identity = body.payload.properties?.__identify as
|
||||
| IIdentifyPayload
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
(body?.payload?.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
return (
|
||||
identity ||
|
||||
(body.payload.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
payload: ITrackHandlerPayload['payload'],
|
||||
) {
|
||||
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
|
||||
const userDefinedTimestamp = path<string>(
|
||||
['properties', '__timestamp'],
|
||||
payload,
|
||||
);
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
'properties' in payload
|
||||
? (payload?.properties?.__timestamp as string | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!userDefinedTimestamp) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
const clientTimestamp = new Date(userDefinedTimestamp);
|
||||
const clientTimestampNumber = clientTimestamp.getTime();
|
||||
|
||||
// Constants for time validation
|
||||
const ONE_MINUTE_MS = 60 * 1000;
|
||||
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
|
||||
|
||||
// Use safeTimestamp if invalid or more than 1 minute in the future
|
||||
if (
|
||||
Number.isNaN(clientTimestamp.getTime()) ||
|
||||
clientTimestamp > new Date(safeTimestamp)
|
||||
Number.isNaN(clientTimestampNumber) ||
|
||||
clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
|
||||
) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
return {
|
||||
timestamp: clientTimestamp.toISOString(),
|
||||
isTimestampFromThePast: true,
|
||||
timestamp: clientTimestampNumber,
|
||||
isTimestampFromThePast,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
path<string>(['properties', '__ip'], request.body.payload) ||
|
||||
request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const projectId = request.client?.projectId;
|
||||
interface TrackContext {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua?: string;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: { value: number; isFromPast: boolean };
|
||||
identity?: IIdentifyPayload;
|
||||
currentDeviceId?: string;
|
||||
previousDeviceId?: string;
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
async function buildContext(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
validatedBody: ITrackHandlerPayload,
|
||||
): Promise<TrackContext> {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
return;
|
||||
throw new HttpError('Missing projectId', { status: 400 });
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
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'];
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
const identity = getIdentity(validatedBody);
|
||||
const profileId = identity?.profileId;
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
if (profileId) {
|
||||
request.body.payload.profileId = profileId;
|
||||
if (profileId && validatedBody.type === 'track') {
|
||||
validatedBody.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
// Get geo location (needed for track and identify)
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
// Generate device IDs if needed (for track)
|
||||
let currentDeviceId: string | undefined;
|
||||
let previousDeviceId: string | undefined;
|
||||
|
||||
if (validatedBody.type === 'track') {
|
||||
const overrideDeviceId =
|
||||
typeof validatedBody.payload.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload.properties.__deviceId
|
||||
: undefined;
|
||||
|
||||
const [salts] = await Promise.all([getSalts()]);
|
||||
currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
: '');
|
||||
previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
];
|
||||
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geo = await getGeoLocation(ip);
|
||||
await identify({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'increment': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
break;
|
||||
}
|
||||
: '';
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
return {
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
headers,
|
||||
timestamp: {
|
||||
value: timestamp.timestamp,
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
|
||||
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: string;
|
||||
isTimestampFromThePast: boolean;
|
||||
}) {
|
||||
async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
const {
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
} = context;
|
||||
|
||||
if (!currentDeviceId || !previousDeviceId) {
|
||||
throw new HttpError('Device ID generation failed', { status: 500 });
|
||||
}
|
||||
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
await eventsGroupQueue.add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
timestamp.value,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
});
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
|
||||
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 (context.identity && Object.keys(context.identity).length > 1) {
|
||||
promises.push(handleIdentify(context.identity, context));
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
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,
|
||||
@@ -322,23 +265,31 @@ async function identify({
|
||||
projectId,
|
||||
properties: {
|
||||
...(payload.properties ?? {}),
|
||||
...(geo ?? {}),
|
||||
...uaInfo,
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -347,12 +298,12 @@ async function increment({
|
||||
);
|
||||
|
||||
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),
|
||||
parsed + direction * (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
|
||||
@@ -364,38 +315,134 @@ 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);
|
||||
}
|
||||
|
||||
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 parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
);
|
||||
const validatedBody = validationResult.data;
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
// Handle alias (not supported)
|
||||
if (validatedBody.type === 'alias') {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed - (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
// Build request context
|
||||
const context = await buildContext(request, validatedBody);
|
||||
|
||||
await upsertProfile({
|
||||
id: profile.id,
|
||||
projectId,
|
||||
properties: profile.properties,
|
||||
isExternal: true,
|
||||
// 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;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
const ip = request.clientIp;
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Missing ip address');
|
||||
}
|
||||
|
||||
const ua = request.headers['user-agent'];
|
||||
if (!ua) {
|
||||
return reply.status(400).send('Missing header: user-agent');
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@ export async function polarWebhook(
|
||||
.parse(event.data.metadata);
|
||||
|
||||
const product = await getProduct(event.data.productId);
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: metadata.organizationId,
|
||||
},
|
||||
});
|
||||
const eventsLimit = product.metadata?.eventsLimit;
|
||||
const subscriptionPeriodEventsLimit =
|
||||
typeof eventsLimit === 'number' ? eventsLimit : undefined;
|
||||
@@ -186,7 +191,9 @@ export async function polarWebhook(
|
||||
where: {
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionStatus: 'active',
|
||||
subscriptionStatus: {
|
||||
in: ['active', 'past_due', 'unpaid'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +223,13 @@ export async function polarWebhook(
|
||||
subscriptionCreatedByUserId: metadata.userId,
|
||||
subscriptionInterval: event.data.recurringInterval,
|
||||
subscriptionPeriodEventsLimit,
|
||||
subscriptionPeriodEventsCountExceededAt:
|
||||
subscriptionPeriodEventsLimit &&
|
||||
organization.subscriptionPeriodEventsCountExceededAt &&
|
||||
organization.subscriptionPeriodEventsLimit <
|
||||
subscriptionPeriodEventsLimit
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function clientHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
31
apps/api/src/hooks/duplicate.hook.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ip = req.clientIp;
|
||||
const origin = req.headers.origin;
|
||||
const clientId = req.headers['openpanel-client-id'];
|
||||
const shouldCheck = ip && origin && clientId;
|
||||
|
||||
const isDuplicate = shouldCheck
|
||||
? await isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload: req.body,
|
||||
projectId: clientId as string,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (isDuplicate) {
|
||||
return reply.status(200).send('Duplicate event');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function fixHook(request: FastifyRequest) {
|
||||
const ua = request.headers['user-agent'];
|
||||
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
|
||||
if (ua) {
|
||||
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
|
||||
const match = ua.match(regex);
|
||||
if (match) {
|
||||
request.headers['user-agent'] = ua.replace(
|
||||
regex,
|
||||
`OpenPanel/${match[1]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function ipHook(request: FastifyRequest) {
|
||||
const ip = getClientIpFromHeaders(request.headers);
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
|
||||
if (ip) {
|
||||
request.clientIp = ip;
|
||||
request.clientIpHeader = header;
|
||||
} else {
|
||||
request.clientIp = '';
|
||||
request.clientIpHeader = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type { TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
type DeprecatedEventPayload = {
|
||||
name: string;
|
||||
properties: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
};
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: TrackHandlerPayload | DeprecatedEventPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, pick } from 'ramda';
|
||||
|
||||
@@ -38,12 +39,7 @@ export async function requestLoggingHook(
|
||||
method: request.method,
|
||||
elapsed: reply.elapsedTime,
|
||||
headers: pick(
|
||||
[
|
||||
'openpanel-client-id',
|
||||
'openpanel-sdk-name',
|
||||
'openpanel-sdk-version',
|
||||
'user-agent',
|
||||
],
|
||||
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
||||
request.headers,
|
||||
),
|
||||
body: request.body,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
liveness,
|
||||
readiness,
|
||||
} from './controllers/healthcheck.controller';
|
||||
import { fixHook } from './hooks/fix.hook';
|
||||
import { ipHook } from './hooks/ip.hook';
|
||||
import { requestIdHook } from './hooks/request-id.hook';
|
||||
import { requestLoggingHook } from './hooks/request-logging.hook';
|
||||
@@ -56,6 +55,7 @@ declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
client: IServiceClientWithProject | null;
|
||||
clientIp: string;
|
||||
clientIpHeader: string;
|
||||
timestamp?: number;
|
||||
session: SessionValidationResult;
|
||||
}
|
||||
@@ -125,7 +125,6 @@ const startServer = async () => {
|
||||
fastify.addHook('onRequest', requestIdHook);
|
||||
fastify.addHook('onRequest', timestampHook);
|
||||
fastify.addHook('onRequest', ipHook);
|
||||
fastify.addHook('onRequest', fixHook);
|
||||
fastify.addHook('onResponse', requestLoggingHook);
|
||||
|
||||
fastify.register(compress, {
|
||||
|
||||
@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { handler } from '@/controllers/track.controller';
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
@@ -12,18 +14,19 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/device-id',
|
||||
handler: fetchDeviceId,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['type', 'payload'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
|
||||
},
|
||||
payload: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
ch,
|
||||
clix,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } 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';
|
||||
|
||||
@@ -27,7 +27,10 @@ export function getReport({
|
||||
- ${chartTypes.metric}
|
||||
- ${chartTypes.bar}
|
||||
`,
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -72,7 +75,10 @@ export function getConversionReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -94,7 +100,10 @@ export function getFunnelReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
|
||||
@@ -3,10 +3,12 @@ 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 type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
IProjectFilterIp,
|
||||
IProjectFilterProfileId,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import { path } from 'ramda';
|
||||
|
||||
@@ -40,7 +42,7 @@ export class SdkAuthError extends Error {
|
||||
|
||||
export async function validateSdkRequest(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const { headers, clientIp } = req;
|
||||
@@ -104,6 +106,22 @@ export async function validateSdkRequest(
|
||||
throw createError('Ingestion: Profile id is blocked by project filter');
|
||||
}
|
||||
|
||||
const revenue =
|
||||
path(['payload', 'properties', '__revenue'], req.body) ??
|
||||
path(['properties', '__revenue'], req.body);
|
||||
|
||||
// Only allow revenue tracking if it was sent with a client secret
|
||||
// or if the project has allowUnsafeRevenueTracking enabled
|
||||
if (
|
||||
!client.project.allowUnsafeRevenueTracking &&
|
||||
!clientSecret &&
|
||||
typeof revenue !== 'undefined'
|
||||
) {
|
||||
throw createError(
|
||||
'Ingestion: Revenue tracking is not allowed without a client secret',
|
||||
);
|
||||
}
|
||||
|
||||
if (client.ignoreCorsAndSecret) {
|
||||
return client;
|
||||
}
|
||||
@@ -135,7 +153,13 @@ export async function validateSdkRequest(
|
||||
}
|
||||
|
||||
if (client.secret && clientSecret) {
|
||||
if (await verifyPassword(clientSecret, client.secret)) {
|
||||
const isVerified = await getCache(
|
||||
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||
60 * 5,
|
||||
async () => await verifyPassword(clientSecret, client.secret!),
|
||||
true,
|
||||
);
|
||||
if (isVerified) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getLock } from '@openpanel/redis';
|
||||
import fastJsonStableHash from 'fast-json-stable-hash';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
export async function isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
ip: string;
|
||||
origin: string;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
|
||||
`fastify:deduplicate:${fastJsonStableHash.hash(
|
||||
{
|
||||
...payload,
|
||||
ip,
|
||||
origin,
|
||||
projectId,
|
||||
},
|
||||
'md5',
|
||||
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkDuplicatedEvent({
|
||||
reply,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
reply: FastifyReply;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
if (await isDuplicatedEvent({ payload, projectId })) {
|
||||
reply.log.info('duplicated event', {
|
||||
payload,
|
||||
projectId,
|
||||
});
|
||||
reply.status(200).send('duplicated');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ch, db } from '@openpanel/db';
|
||||
import {
|
||||
cronQueue,
|
||||
eventsGroupQueue,
|
||||
eventsGroupQueues,
|
||||
miscQueue,
|
||||
notificationQueue,
|
||||
sessionsQueue,
|
||||
@@ -71,7 +71,7 @@ export async function shutdown(
|
||||
// Step 6: Close Bull queues (graceful shutdown of queue state)
|
||||
try {
|
||||
await Promise.all([
|
||||
eventsGroupQueue.close(),
|
||||
...eventsGroupQueues.map((queue) => queue.close()),
|
||||
sessionsQueue.close(),
|
||||
cronQueue.close(),
|
||||
miscQueue.close(),
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
BIN
apps/justfuckinguseopenpanel/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
505
apps/justfuckinguseopenpanel/index.html
Normal file
@@ -0,0 +1,505 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
|
||||
<meta name="author" content="OpenPanel">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta property="og:image" content="/ogimage.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="twitter:image" content="/ogimage.png">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Just Fucking Use OpenPanel</h1>
|
||||
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
||||
|
||||
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
|
||||
|
||||
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
|
||||
<ul>
|
||||
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
|
||||
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
|
||||
</ul>
|
||||
|
||||
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
|
||||
|
||||
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
|
||||
|
||||
<h2>The Web-Only Analytics Trap</h2>
|
||||
|
||||
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
|
||||
|
||||
<blockquote>
|
||||
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
|
||||
</blockquote>
|
||||
|
||||
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
|
||||
|
||||
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
|
||||
|
||||
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
|
||||
|
||||
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
|
||||
|
||||
<h2>Counter One Dollar Stats</h2>
|
||||
|
||||
<p>"$1/month for page views. Adorable."</p>
|
||||
|
||||
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
|
||||
|
||||
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
|
||||
|
||||
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>You get:</p>
|
||||
<ul>
|
||||
<li>Funnels to see where users drop off</li>
|
||||
<li>Retention analysis to see who comes back</li>
|
||||
<li>Cohorts to segment your users</li>
|
||||
<li>User profiles to understand individual behavior</li>
|
||||
<li>Custom dashboards to see what matters to YOU</li>
|
||||
<li>Revenue tracking to see what actually makes money</li>
|
||||
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
|
||||
</ul>
|
||||
|
||||
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
|
||||
|
||||
<h2>Why OpenPanel is the Answer</h2>
|
||||
|
||||
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
|
||||
|
||||
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
|
||||
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
|
||||
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
|
||||
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
|
||||
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
|
||||
</ul>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
|
||||
|
||||
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
|
||||
|
||||
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
|
||||
|
||||
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
|
||||
|
||||
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
|
||||
|
||||
<h2>The Comparison Table (The Brutal Truth)</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Price at 20M events</th>
|
||||
<th>What You Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Mixpanel</strong></td>
|
||||
<td>$2,300+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>PostHog</strong></td>
|
||||
<td>$1,982+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plausible</strong></td>
|
||||
<td>Various pricing</td>
|
||||
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>One Dollar Stats</strong></td>
|
||||
<td>$1/month</td>
|
||||
<td>Page views (but cheaper!)</td>
|
||||
</tr>
|
||||
<tr style="background: #131313; border: 2px solid #3b82f6;">
|
||||
<td><strong>OpenPanel</strong></td>
|
||||
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
|
||||
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The Bottom Fucking Line</h2>
|
||||
|
||||
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
|
||||
|
||||
<p>You have three choices:</p>
|
||||
|
||||
<ol>
|
||||
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
|
||||
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
|
||||
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
|
||||
</ol>
|
||||
|
||||
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
|
||||
|
||||
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Ready to understand what your users actually do?</h2>
|
||||
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
|
||||
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
|
||||
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
|
||||
</figure>
|
||||
|
||||
<footer>
|
||||
<p><strong>Just Fucking Use OpenPanel</strong></p>
|
||||
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
|
||||
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/justfuckinguseopenpanel/ogimage.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/overview-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/profile-dark.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/report-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
7
apps/justfuckinguseopenpanel/wrangler.jsonc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
2
apps/public/.gitignore
vendored
@@ -2,8 +2,6 @@
|
||||
/node_modules
|
||||
|
||||
# generated content
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
|
||||
# test & build
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
ARG NODE_VERSION=20.15.1
|
||||
|
||||
FROM --platform=linux/amd64 node:${NODE_VERSION}-slim AS base
|
||||
|
||||
ARG DATABASE_URL
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG REDIS_URL
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ARG CLICKHOUSE_URL
|
||||
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/public/package.json apps/public/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/redis/package.json packages/redis/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/common/package.json packages/common/package.json
|
||||
COPY packages/constants/package.json packages/constants/package.json
|
||||
COPY packages/validation/package.json packages/validation/package.json
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
|
||||
COPY packages/sdks/_info/package.json packages/sdks/_info/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps/public apps/public
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/public /app/apps/public
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/redis /app/packages/redis
|
||||
COPY --from=build /app/packages/common /app/packages/common
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
COPY --from=build /app/packages/constants /app/packages/constants
|
||||
COPY --from=build /app/packages/validation /app/packages/validation
|
||||
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
|
||||
COPY --from=build /app/packages/sdks/_info /app/packages/sdks/_info
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
|
||||
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -15,6 +15,25 @@ yarn dev
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js and Fumadocs, take a look at the following
|
||||
@@ -23,4 +42,4 @@ resources:
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||
features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
|
||||
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { url, getAuthor } from '@/app/layout.config';
|
||||
import { SingleSwirl } from '@/components/Swirls';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { SectionHeader } from '@/components/section';
|
||||
import { Toc } from '@/components/toc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const author = getAuthor(article?.data.team);
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
title: 'Article Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
authors: [{ name: author.name }],
|
||||
alternates: {
|
||||
canonical: url(article.url),
|
||||
},
|
||||
openGraph: {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
type: 'article',
|
||||
publishedTime: article.data.date.toISOString(),
|
||||
authors: author.name,
|
||||
images: url(article.data.cover),
|
||||
url: url(article.url),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
images: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}) {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const Body = article?.data.body;
|
||||
const author = getAuthor(article?.data.team);
|
||||
const goBackUrl = '/articles';
|
||||
|
||||
const relatedArticles = (await articleSource.getPages())
|
||||
.filter(
|
||||
(item) =>
|
||||
item.data.tag === article?.data.tag && item.url !== article?.url,
|
||||
)
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
if (!Body) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Create the JSON-LD data
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article?.data.title,
|
||||
datePublished: article?.data.date.toISOString(),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url(article.url),
|
||||
},
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="article-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<article className="container max-w-5xl col">
|
||||
<div className="py-16">
|
||||
<Link
|
||||
href={goBackUrl}
|
||||
className="flex items-center gap-2 mb-4 text-muted-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
<span>Back to all articles</span>
|
||||
</Link>
|
||||
<div className="flex-col-reverse col md:row gap-8">
|
||||
<div className="col flex-1">
|
||||
<h1 className="text-5xl font-bold leading-tight">
|
||||
{article?.data.title}
|
||||
</h1>
|
||||
|
||||
<div className="row gap-4 items-center mt-8">
|
||||
<div className="size-10 center-center bg-black rounded-full">
|
||||
{author.image ? (
|
||||
<Image
|
||||
className="size-10 object-cover rounded-full"
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
) : (
|
||||
<Logo className="w-6 h-6 fill-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="col">
|
||||
<p className="font-medium">{author.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{article?.data.date.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
|
||||
<div className="min-w-0">
|
||||
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
|
||||
<Body />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="pl-12 pb-12 gap-8 col">
|
||||
<Toc toc={article?.data.toc} />
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl py-16">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0 size-[300px]" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50 size-[300px]" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
title="Try it"
|
||||
description="Give it a spin for free. No credit card required."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{relatedArticles.length > 0 && (
|
||||
<div className="my-16">
|
||||
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{relatedArticles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
const title = 'Articles';
|
||||
const description = 'Read our latest articles';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: url('/articles'),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const articles = (await articleSource.getPages()).sort(
|
||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="container col">
|
||||
<div className="py-16">
|
||||
<h1 className="text-center text-7xl font-bold">Articles</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{articles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="overflow-hidden">
|
||||
<HeroContainer className="h-screen pointer-events-none" />
|
||||
<div className="absolute h-screen inset-0 radial-gradient-dot-pages select-none pointer-events-none" />
|
||||
<div className="-mt-[calc(100vh-100px)] relative min-h-[500px] pb-12 -mb-24">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { SupporterPerks } from '@/components/sections/supporter-perks';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
HeartHandshakeIcon,
|
||||
SparklesIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
alternates: {
|
||||
canonical: url('/supporter'),
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
type: 'website',
|
||||
url: url('/supporter'),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
},
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Become a Supporter',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url('/supporter'),
|
||||
},
|
||||
};
|
||||
|
||||
export default function SupporterPage() {
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
id="supporter-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 text-center">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-center">
|
||||
<HeartHandshakeIcon className="size-4 text-rose-600" />
|
||||
Support Open-Source Analytics
|
||||
</Tag>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold leading-[1.1]">
|
||||
Help us build the future of{' '}
|
||||
<span className="text-primary">open analytics</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Your support accelerates development, funds infrastructure, and
|
||||
helps us build features faster. Plus, you get exclusive perks
|
||||
and early access to everything we ship.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-4 justify-center items-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter
|
||||
<SparklesIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting at $20/month • Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<div className="container max-w-7xl">
|
||||
{/* Main Content with Sidebar */}
|
||||
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
|
||||
{/* Main Content */}
|
||||
<div className="col gap-12">
|
||||
{/* Why Support Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">Why your support matters</h2>
|
||||
<div className="col gap-6 text-muted-foreground">
|
||||
<p className="text-lg">
|
||||
We're not a big corporation – just a small team passionate
|
||||
about building something useful for developers. OpenPanel
|
||||
started because we believed analytics tools shouldn't be
|
||||
complicated or locked behind expensive enterprise
|
||||
subscriptions.
|
||||
</p>
|
||||
<p>When you become a supporter, you're directly funding:</p>
|
||||
<ul className="col gap-3 list-none pl-0">
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Active Development
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
More time fixing bugs, adding features, and improving
|
||||
documentation
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Infrastructure
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Keeping servers running, CI/CD pipelines, and
|
||||
development tools
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">Independence</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Staying focused on what matters: building a tool
|
||||
developers actually want
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
No corporate speak, no fancy promises – just honest work on
|
||||
making OpenPanel better for everyone. Every contribution, no
|
||||
matter the size, helps us stay independent and focused on what
|
||||
matters.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What You Get Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">
|
||||
What you get as a supporter
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
🚀 Latest Docker Images
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get bleeding-edge builds on every commit. Access new
|
||||
features weeks before public release.
|
||||
</p>
|
||||
<Link
|
||||
href="/docs/self-hosting/supporter-access-latest-docker-images"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
💬 Prioritized Support
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get help faster with priority support in our Discord
|
||||
community. Your questions get answered first.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
✨ Feature Requests
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Your ideas and feature requests get prioritized in our
|
||||
roadmap. Shape the future of OpenPanel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
⭐ Exclusive Discord Role
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Special badge and recognition in our community. Show your
|
||||
support with pride.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Impact Section */}
|
||||
<section className="p-8 rounded-xl border bg-gradient-to-br from-primary/5 to-primary/10">
|
||||
<h2 className="text-2xl font-bold mb-4">Your impact</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Every dollar you contribute goes directly into development,
|
||||
infrastructure, and making OpenPanel better. Here's what your
|
||||
support enables:
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
100%
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Open Source
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
24/7
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Active Development
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">∞</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Self-Hostable
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:block hidden">
|
||||
<SupporterPerks />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Mobile Perks */}
|
||||
<div className="lg:hidden mb-16">
|
||||
<SupporterPerks />
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
Starting at $20/month
|
||||
</Tag>
|
||||
}
|
||||
title="Ready to support OpenPanel?"
|
||||
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
|
||||
/>
|
||||
<div className="center-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter Now
|
||||
<HeartHandshakeIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {
|
||||
createNextRouteHandler,
|
||||
createScriptHandler,
|
||||
} from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
export const GET = createScriptHandler();
|
||||
@@ -1,4 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -1,67 +0,0 @@
|
||||
import { url, siteName } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from 'fumadocs-ui/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
canonical: url(page.url),
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
url: url(page.url),
|
||||
type: 'article',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--green: 156 71% 67%;
|
||||
--red: 351 89% 72%;
|
||||
--background: 0 0% 98%;
|
||||
--background-light: 0 0% 100%;
|
||||
--background-dark: 0 0% 96%;
|
||||
--foreground: 0 0% 9%;
|
||||
--foreground-dark: 0 0% 7.5%;
|
||||
--foreground-light: 0 0% 11%;
|
||||
--card: 0 0% 98%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 98%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 9%;
|
||||
--background-dark: 0 0% 7.5%;
|
||||
--background-light: 0 0% 11%;
|
||||
--foreground: 0 0% 98%;
|
||||
--foreground-light: 0 0% 100%;
|
||||
--foreground-dark: 0 0% 96%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply !bg-[hsl(var(--background))] text-foreground font-sans text-base antialiased flex flex-col min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-6 md:px-10 lg:px-14 w-full;
|
||||
}
|
||||
|
||||
.pulled {
|
||||
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
|
||||
}
|
||||
|
||||
.row {
|
||||
@apply flex flex-row;
|
||||
}
|
||||
|
||||
.col {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.center-center {
|
||||
@apply flex items-center justify-center text-center;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
|
||||
.radial-gradient {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at bottom, hsl(var(--background-light)), hsl(var(--background)));
|
||||
}
|
||||
|
||||
.radial-gradient-dot-1 {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 20%, hsl(var(--background-light)), transparent);
|
||||
}
|
||||
|
||||
.radial-gradient-dot-pages {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 50%, hsl(var(--background)), hsl(var(--background)/0.2));
|
||||
}
|
||||
|
||||
.animated-iframe-gradient {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.animated-iframe-gradient:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
background: linear-gradient(250deg, hsl(var(--foreground)/0.9), transparent);
|
||||
|
||||
animation: GradientRotate 8s linear infinite;
|
||||
}
|
||||
@keyframes GradientRotate {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.line-before {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-before:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
.line-after {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-after:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
right: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: animateFadeUp 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeUp {
|
||||
0% { transform: translateY(0.5rem); scale: 0.95; }
|
||||
100% { transform: translateY(0); scale: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-down {
|
||||
animation: animateFadeDown 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeDown {
|
||||
0% { transform: translateY(-1rem); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Docs */
|
||||
|
||||
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: hsl(var(--background-dark));
|
||||
border: 1px solid hsl(var(--background-light));
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] pre{
|
||||
max-height: none;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
/**
|
||||
* Shared layout configurations
|
||||
*
|
||||
* you can configure layouts individually from:
|
||||
* Home Layout: app/(home)/layout.tsx
|
||||
* Docs Layout: app/docs/layout.tsx
|
||||
*/
|
||||
|
||||
export const siteName = 'OpenPanel';
|
||||
export const baseUrl = 'https://openpanel.dev';
|
||||
export const url = (path: string) => `${baseUrl}${path}`;
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: siteName,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Home',
|
||||
url: '/',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Pricing',
|
||||
url: '/pricing',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Supporter',
|
||||
url: '/supporter',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Documentation',
|
||||
url: '/docs',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Articles',
|
||||
url: '/articles',
|
||||
active: 'nested-url',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export const getAuthor = (author?: string) => {
|
||||
return authors.find((a) => a.name === author)!;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import './global.css';
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||
import { cn } from 'fumadocs-ui/components/api';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import Script from 'next/script';
|
||||
import { url, baseUrl, siteName } from './layout.config';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
|
||||
],
|
||||
};
|
||||
|
||||
const description = `${siteName} is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: siteName,
|
||||
template: `%s | ${siteName}`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical: baseUrl,
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
title: siteName,
|
||||
description,
|
||||
siteName: siteName,
|
||||
url: baseUrl,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={cn(GeistSans.variable, GeistMono.variable)}>
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op"
|
||||
cdnUrl="/api/op/op1.js"
|
||||
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
trackAttributes
|
||||
trackScreenViews
|
||||
trackOutgoingLinks
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { metadata } from './layout';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: metadata.title as string,
|
||||
short_name: 'Openpanel.dev',
|
||||
description: metadata.description!,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#fff',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function NotFound({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<HeroContainer className="h-screen center-center">
|
||||
<div className="relative z-10 col gap-2">
|
||||
<div className="text-[150px] font-mono font-bold opacity-5 -mb-4">
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold">Not Found</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Awkward, we couldn't find what you were looking for.
|
||||
</p>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { Hero } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { Features } from '@/components/sections/features';
|
||||
import { Pricing } from '@/components/sections/pricing';
|
||||
import { Sdks } from '@/components/sections/sdks';
|
||||
import { Stats, StatsPure } from '@/components/sections/stats';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { WhyOpenPanel } from '@/components/why-openpanel';
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OpenPanel | An open-source alternative to Mixpanel',
|
||||
};
|
||||
|
||||
// export const experimental_ppr = true;
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
<Pricing />
|
||||
<Sdks />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image, { type ImageProps } from 'next/image';
|
||||
|
||||
type SwirlProps = Omit<ImageProps, 'src' | 'alt'>;
|
||||
|
||||
export function SingleSwirl({ className, ...props }: SwirlProps) {
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
src="/swirl-2.png"
|
||||
alt="Swirl"
|
||||
className={cn(
|
||||
'pointer-events-none w-full h-full object-cover',
|
||||
className,
|
||||
)}
|
||||
width={1200}
|
||||
height={1200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DoubleSwirl({ className, ...props }: SwirlProps) {
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
src="/swirl.png"
|
||||
alt="Swirl"
|
||||
className={cn(
|
||||
'pointer-events-none w-full h-full object-cover',
|
||||
className,
|
||||
)}
|
||||
width={1200}
|
||||
height={1200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, ConeIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function SmallFeature({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background-light rounded-lg p-1 border border-border group',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-background-dark rounded-lg p-8 h-full group-hover:bg-background-light transition-colors">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Feature({
|
||||
children,
|
||||
media,
|
||||
reverse = false,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
media?: React.ReactNode;
|
||||
reverse?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border rounded-lg bg-background-light overflow-hidden p-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 md:grid-cols-2 gap-4 items-center',
|
||||
!media && '!grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<div className={cn(reverse && 'md:order-last', 'p-10')}>{children}</div>
|
||||
{media && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background-dark h-full rounded-md overflow-hidden',
|
||||
reverse && 'md:order-first',
|
||||
)}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureContent({
|
||||
icon,
|
||||
title,
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
content: React.ReactNode[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{icon && (
|
||||
<div
|
||||
data-icon
|
||||
className="bg-foreground text-background rounded-md p-4 inline-block mb-6 transition-colors"
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-medium mb-2">{title}</h2>
|
||||
<div className="col gap-2">
|
||||
{content.map((c, i) => (
|
||||
<p className="text-muted-foreground" key={i.toString()}>
|
||||
{c}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureListItem({
|
||||
icon: Icon,
|
||||
title,
|
||||
}: { icon: React.ComponentType<any>; title: string }) {
|
||||
return (
|
||||
<div className="row items-center gap-2" key="funnel">
|
||||
<Icon className="size-4 text-foreground/70" strokeWidth={1.5} /> {title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureList({
|
||||
title,
|
||||
items,
|
||||
className,
|
||||
cols = 2,
|
||||
}: {
|
||||
title: string;
|
||||
items: React.ReactNode[];
|
||||
className?: string;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="font-semibold text-sm mb-2">{title}</h3>
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-2 [&>div]:p-2 [&>div]:row [&>div]:items-center [&>div]:gap-2 grid',
|
||||
cols === 1 && 'grid-cols-1',
|
||||
cols === 2 && 'grid-cols-2',
|
||||
cols === 3 && 'grid-cols-3',
|
||||
)}
|
||||
>
|
||||
{items.map((i, j) => (
|
||||
<div key={j.toString()}>{i}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureMore({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'font-medium items-center row justify-between border-t py-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children} <ChevronRightIcon className="size-4" strokeWidth={1.5} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { SingleSwirl } from './Swirls';
|
||||
import { Logo } from './logo';
|
||||
import { SectionHeader } from './section';
|
||||
import { Tag } from './tag';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div className="mt-32">
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
tag={<Tag>Discover User Insights</Tag>}
|
||||
title="Effortless web & product analytics"
|
||||
description="Simplify your web & product analytics with our user-friendly platform. Collect, analyze, and optimize your data in minutes."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="pt-32 text-sm relative overflow-hidden">
|
||||
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
|
||||
<Logo className="w-[900px] shrink-0" />
|
||||
</div>
|
||||
<div className="container grid grid-cols-1 md:grid-cols-5 gap-12 md:gap-8 relative">
|
||||
<div>
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions.nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Useful links</h3>
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
<li>
|
||||
<Link href="/pricing">Pricing</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/about">About</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact">Contact</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/supporter">Become a supporter</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3 ">
|
||||
<h3 className="font-medium">Articles</h3>
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
<li>
|
||||
<Link href="/articles/vs-mixpanel">OpenPanel vs Mixpanel</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/articles/mixpanel-alternatives">
|
||||
Mixpanel alternatives
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 md:items-end col gap-4">
|
||||
<div className="[&_svg]:size-6 row gap-4">
|
||||
<Link
|
||||
title="Go to GitHub"
|
||||
href="https://github.com/Openpanel-dev/openpanel"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Go to X"
|
||||
href="https://x.com/openpaneldev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>X</title>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Join Discord"
|
||||
href="https://go.openpanel.dev/discord"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Send an email"
|
||||
href="mailto:hello@openpanel.dev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<MailIcon className="size-6" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://status.openpanel.dev"
|
||||
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<span>Operational</span>
|
||||
<div className="size-2 bg-emerald-500 rounded-full" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground border-t pt-4 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||
<div className="container col md:row justify-between">
|
||||
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
|
||||
<div className="col lg:row gap-2 md:gap-4">
|
||||
<Link href="/sitemap.xml">Sitemap</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/terms">Terms of Service</Link>
|
||||
<Link href="/cookies">Cookie Policy (just kidding)</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import NextImage from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Frame = {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
Component: React.ComponentType;
|
||||
};
|
||||
|
||||
function LivePreview() {
|
||||
return (
|
||||
<iframe
|
||||
src={
|
||||
'https://dashboard.openpanel.dev/share/overview/zef2XC?header=0&range=30d'
|
||||
}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Image({ src }: { src: string }) {
|
||||
return (
|
||||
<div>
|
||||
<NextImage
|
||||
className="w-full h-full block dark:hidden"
|
||||
src={`/${src}-light.png`}
|
||||
alt={`${src} light`}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
<NextImage
|
||||
className="w-full h-full hidden dark:block"
|
||||
src={`/${src}-dark.png`}
|
||||
alt={`${src} dark`}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroCarousel() {
|
||||
const frames: Frame[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
key: 'overview',
|
||||
label: 'Live preview',
|
||||
Component: LivePreview,
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
key: 'analytics',
|
||||
label: 'Product analytics',
|
||||
Component: () => <Image src="dashboard" />,
|
||||
},
|
||||
{
|
||||
id: 'funnels',
|
||||
key: 'funnels',
|
||||
label: 'Funnels',
|
||||
Component: () => <Image src="funnel" />,
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
key: 'retention',
|
||||
label: 'Retention',
|
||||
Component: () => <Image src="retention" />,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
key: 'profile',
|
||||
label: 'Inspect profile',
|
||||
Component: () => <Image src="profile" />,
|
||||
},
|
||||
];
|
||||
|
||||
const [activeFrames, setActiveFrames] = useState<Frame[]>([frames[0]]);
|
||||
const activeFrame = activeFrames[activeFrames.length - 1];
|
||||
|
||||
return (
|
||||
<div className="col gap-6 w-full">
|
||||
<div className="flex-wrap row gap-x-4 gap-y-2 justify-center [&>div]:font-medium mt-1">
|
||||
{frames.map((frame) => (
|
||||
<div key={frame.id} className="relative">
|
||||
<Button
|
||||
variant="naked"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeFrame.id === frame.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFrame = {
|
||||
...frame,
|
||||
key: Math.random().toString().slice(2, 11),
|
||||
};
|
||||
|
||||
setActiveFrames((p) => [...p.slice(-2), newFrame]);
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{frame.label}
|
||||
</Button>
|
||||
<motion.div
|
||||
className="h-1 bg-foreground rounded-full"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: activeFrame.id === frame.id ? '100%' : '0%',
|
||||
opacity: activeFrame.id === frame.id ? 1 : 0,
|
||||
}}
|
||||
whileHover={{
|
||||
width: '100%',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pulled animated-iframe-gradient p-px pb-0 rounded-t-xl">
|
||||
<div className="overflow-hidden rounded-xl rounded-b-none w-full bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full h-[750px]',
|
||||
activeFrame.id !== 'overview' && 'h-auto aspect-[5/3]',
|
||||
)}
|
||||
>
|
||||
{activeFrames.slice(-1).map((frame) => (
|
||||
<div key={frame.key} className="absolute inset-0 w-full h-full">
|
||||
<div className="bg-background rounded-xl h-full w-full">
|
||||
<frame.Component />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { WorldMap } from './world-map';
|
||||
|
||||
export function HeroMap() {
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
const y = useTransform(scrollY, [0, 250], [0, 50], { clamp: true });
|
||||
const scale = useTransform(scrollY, [0, 250], [1, 1.1], { clamp: true });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={{ y, scale }}
|
||||
className="absolute inset-0 top-20 center-center items-start select-none"
|
||||
>
|
||||
<div className="min-w-[1400px] w-full">
|
||||
<WorldMap />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ChevronRightIcon,
|
||||
CookieIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
FlaskRoundIcon,
|
||||
GithubIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Competition } from './competition';
|
||||
import { Tag } from './tag';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:row sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-start">
|
||||
<StarIcon className="size-4 fill-yellow-500 text-yellow-500" />
|
||||
Trusted by +2000 projects
|
||||
</Tag>
|
||||
<h1
|
||||
className="text-4xl md:text-5xl font-extrabold leading-[1.1]"
|
||||
title="An open-source alternative to Mixpanel"
|
||||
>
|
||||
An open-source alternative to <Competition />
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines
|
||||
the power of Mixpanel with the ease of Plausible and one of the
|
||||
best Google Analytics replacements.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" asChild className="group w-72">
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started now
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{perks.map((perk) => (
|
||||
<li key={perk.text} className="text-sm text-muted-foreground">
|
||||
<perk.icon className="size-4 inline-block mr-1" />
|
||||
{perk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col sm:w-1/2 relative group">
|
||||
<div
|
||||
className={cn([
|
||||
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
|
||||
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-32',
|
||||
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
|
||||
])}
|
||||
>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
{/* URL bar */}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background rounded-md border border-border flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground flex-1">
|
||||
https://demo.openpanel.dev
|
||||
</span>
|
||||
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 top-12 center-center group-hover:bg-foreground/20 transition-colors">
|
||||
<Button
|
||||
asChild
|
||||
className="group-hover:opacity-100 opacity-0 transition-opacity pointer-events-auto"
|
||||
>
|
||||
<Link
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
Test live demo
|
||||
<FlaskRoundIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className={cn('radial-gradient overflow-hidden relative')}>
|
||||
<div className={cn('relative z-10', className)}>{children}</div>
|
||||
|
||||
{/* Shadow bottom */}
|
||||
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PlusLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('absolute', className)}>
|
||||
<div className="relative">
|
||||
<div className="w-px h-8 bg-foreground/40 -bottom-4 left-0 absolute animate-pulse" />
|
||||
<div className="w-8 h-px bg-foreground/40 -bottom-px -left-4 absolute animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerticalLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-px bg-gradient-to-t from-transparent via-foreground/30 to-transparent absolute -top-12 -bottom-12',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px bg-gradient-to-r from-transparent via-foreground/30 to-transparent absolute left-0 right-0',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { GithubButton } from './github-button';
|
||||
import { Logo } from './logo';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const navbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If click outside of the menu, close it
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleClick);
|
||||
return () => window.removeEventListener('click', handleClick);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<nav className="fixed top-4 z-50 w-full" ref={navbarRef}>
|
||||
<div className="container">
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between border border-transparent backdrop-blur-lg items-center p-4 -mx-4 rounded-full transition-colors',
|
||||
isScrolled
|
||||
? 'bg-background/90 border-foreground/10'
|
||||
: 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions.nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row items-center gap-8">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8 text-sm">
|
||||
{baseOptions.links?.map((link) => {
|
||||
if (link.type !== 'main') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground font-medium"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubButton />
|
||||
{/* Sign in button */}
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="hidden md:flex"
|
||||
href="https://dashboard.openpanel.dev/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="md:hidden -my-2"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen((p) => !p);
|
||||
}}
|
||||
>
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden -mx-4"
|
||||
>
|
||||
<div className="rounded-xl bg-background/90 border border-foreground/10 mt-4 md:hidden backdrop-blur-lg">
|
||||
<div className="col text-sm divide-y divide-foreground/10">
|
||||
{baseOptions.links?.map((link) => {
|
||||
if (link.type !== 'main') return null;
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground text-xl font-medium p-4 px-4"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <section className={cn('my-32 col', className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
tag?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('col gap-4 center-center mb-16', className)}>
|
||||
{tag}
|
||||
<h2 className="text-4xl font-medium">{title}</h2>
|
||||
<p className="text-muted-foreground max-w-screen-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ShieldQuestionIcon } from 'lucide-react';
|
||||
import Script from 'next/script';
|
||||
import { Section, SectionHeader } from '../section';
|
||||
import { Tag } from '../tag';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '../ui/accordion';
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: 'Does OpenPanel have a free tier?',
|
||||
answer: [
|
||||
'For our Cloud plan we offer a 30 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
|
||||
'OpenPanel is also open-source and you can self-host it for free!',
|
||||
'',
|
||||
'Why does OpenPanel not have a free tier?',
|
||||
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Is everything really unlimited?',
|
||||
answer: [
|
||||
'Everything except the amount of events is unlimited.',
|
||||
'We do not limit the amount of users, projects, dashboards, etc. We want a transparent and fair pricing model and we think unlimited is the best way to do this.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between web and product analytics?',
|
||||
answer: [
|
||||
'Web analytics focuses on website traffic metrics like page views, bounce rates, and visitor sources. Product analytics goes deeper into user behavior, tracking specific actions, user journeys, and feature usage within your application.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Do I need to modify my code to use OpenPanel?',
|
||||
answer: [
|
||||
'Minimal setup is required. Simply add our lightweight JavaScript snippet to your website or use one of our SDKs for your preferred framework. Most common frameworks like React, Vue, and Next.js are supported.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Is my data GDPR compliant?',
|
||||
answer: [
|
||||
'Yes, OpenPanel is fully GDPR compliant. We collect only essential data, do not use cookies for tracking, and provide tools to help you maintain compliance with privacy regulations.',
|
||||
'You can self-host OpenPanel to keep full control of your data.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Mixpanel?',
|
||||
answer: [
|
||||
'OpenPanel offers most of Mixpanel report features such as funnels, retention and visualizations of your data. If you miss something, please let us know. The biggest difference is that OpenPanel offers better web analytics.',
|
||||
'Other than that OpenPanel is way cheaper and can also be self-hosted.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Plausible?',
|
||||
answer: [
|
||||
`OpenPanel's web analytics is inspired by Plausible like many other analytics tools. The difference is that OpenPanel offers more tools for product analytics and better support for none web devices (iOS,Android and servers).`,
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Google Analytics?',
|
||||
answer: [
|
||||
'OpenPanel offers a more privacy-focused, user-friendly alternative to Google Analytics. We provide real-time data, no sampling, and more intuitive product analytics features.',
|
||||
'Unlike GA4, our interface is designed to be simple yet powerful, making it easier to find the insights you need.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I export my data?',
|
||||
answer: [
|
||||
'Currently you can export your data with our API. Depending on how many events you have this can be an issue.',
|
||||
'We are working on better export options and will be finished around Q1 2025.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What kind of support do you offer?',
|
||||
answer: ['Currently we offer support through GitHub and Discord.'],
|
||||
},
|
||||
];
|
||||
|
||||
export default Faq;
|
||||
export function Faq() {
|
||||
// Create the JSON-LD structured data
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: questions.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: q.answer.join(' '),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
{/* Add the JSON-LD script */}
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
|
||||
Get answers today
|
||||
</Tag>
|
||||
}
|
||||
title="FAQ"
|
||||
description="Some of the most common questions we get asked."
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center"
|
||||
>
|
||||
{questions.map((q) => (
|
||||
<AccordionItem value={q.question} key={q.question}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="max-w-2xl col gap-2">
|
||||
{q.answer.map((a) => (
|
||||
<p key={a}>{a}</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import {
|
||||
Feature,
|
||||
FeatureContent,
|
||||
FeatureList,
|
||||
FeatureListItem,
|
||||
FeatureMore,
|
||||
SmallFeature,
|
||||
} from '@/components/feature';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import {
|
||||
ActivityIcon,
|
||||
AreaChartIcon,
|
||||
BarChart2Icon,
|
||||
BarChartIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CloudIcon,
|
||||
ConeIcon,
|
||||
CookieIcon,
|
||||
DatabaseIcon,
|
||||
GithubIcon,
|
||||
LayersIcon,
|
||||
LineChartIcon,
|
||||
LockIcon,
|
||||
MapIcon,
|
||||
PieChartIcon,
|
||||
ServerIcon,
|
||||
Share2Icon,
|
||||
ShieldIcon,
|
||||
UserIcon,
|
||||
WalletIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import { BatteryIcon } from '../battery-icon';
|
||||
import { EventsFeature } from './features/events-feature';
|
||||
import { ProductAnalyticsFeature } from './features/product-analytics-feature';
|
||||
import { ProfilesFeature } from './features/profiles-feature';
|
||||
import { WebAnalyticsFeature } from './features/web-analytics-feature';
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
tag={
|
||||
<Tag>
|
||||
<BatteryIcon className="size-4" strokeWidth={1.5} />
|
||||
Batteries included
|
||||
</Tag>
|
||||
}
|
||||
title="Everything you need"
|
||||
description="We have combined the best features from the most popular analytics tools into one simple to use platform."
|
||||
/>
|
||||
<div className="col gap-16">
|
||||
<Feature media={<WebAnalyticsFeature />}>
|
||||
<FeatureContent
|
||||
title="Web analytics"
|
||||
content={[
|
||||
'Privacy-friendly analytics with all the important metrics you need, in a simple and modern interface.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Get a quick overview"
|
||||
items={[
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Visitors" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Referrals" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Top pages" />,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Top entries"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Top exists"
|
||||
/>,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Devices" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Sessions" />,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Bounce rate"
|
||||
/>,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Duration" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Geography" />,
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
|
||||
<Feature reverse media={<ProductAnalyticsFeature />}>
|
||||
<FeatureContent
|
||||
title="Product analytics"
|
||||
content={[
|
||||
'Turn data into decisions with powerful visualizations and real-time insights.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Understand your product"
|
||||
items={[
|
||||
<FeatureListItem key="funnel" icon={ConeIcon} title="Funnel" />,
|
||||
<FeatureListItem
|
||||
key="retention"
|
||||
icon={UserIcon}
|
||||
title="Retention"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="bar"
|
||||
icon={BarChartIcon}
|
||||
title="A/B tests"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="pie"
|
||||
icon={PieChartIcon}
|
||||
title="Conversion"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Supported charts"
|
||||
items={[
|
||||
<FeatureListItem key="line" icon={LineChartIcon} title="Line" />,
|
||||
<FeatureListItem key="bar" icon={BarChartIcon} title="Bar" />,
|
||||
<FeatureListItem key="pie" icon={PieChartIcon} title="Pie" />,
|
||||
<FeatureListItem key="area" icon={AreaChartIcon} title="Area" />,
|
||||
<FeatureListItem
|
||||
key="histogram"
|
||||
icon={BarChart2Icon}
|
||||
title="Histogram"
|
||||
/>,
|
||||
<FeatureListItem key="map" icon={MapIcon} title="Map" />,
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
|
||||
<FeatureContent
|
||||
icon={<ClockIcon className="size-8" strokeWidth={1} />}
|
||||
title="Real time analytics"
|
||||
content={[
|
||||
'Get instant insights into your data. No need to wait for data to be processed, like other tools out there, looking at you GA4...',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
|
||||
<FeatureContent
|
||||
icon={<DatabaseIcon className="size-8" strokeWidth={1} />}
|
||||
title="Own your own data"
|
||||
content={[
|
||||
'Own your data, no vendor lock-in. Export all your data with our export API.',
|
||||
'Self-host it on your own infrastructure to have complete control.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<CloudIcon className="size-8" strokeWidth={1} />}
|
||||
title="Cloud or self-hosted"
|
||||
content={[
|
||||
'We offer a cloud version of the platform, but you can also self-host it on your own infrastructure.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="/docs/self-hosting/self-hosting"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
More about self-hosting
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-green-500">
|
||||
<FeatureContent
|
||||
icon={<CookieIcon className="size-8" strokeWidth={1} />}
|
||||
title="No cookies"
|
||||
content={[
|
||||
'We care about your privacy, so our tracker does not use cookies. This keeps your data safe and secure.',
|
||||
'We follow GDPR guidelines closely, ensuring your personal information is protected without using invasive technologies.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="/articles/cookieless-analytics"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
Cookieless analytics
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-gray-500">
|
||||
<FeatureContent
|
||||
icon={<GithubIcon className="size-8" strokeWidth={1} />}
|
||||
title="Open-source"
|
||||
content={[
|
||||
'Our code is open and transparent. Contribute, fork, or learn from our implementation.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="https://git.new/openpanel"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
View the code
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
|
||||
<FeatureContent
|
||||
icon={<LockIcon className="size-8" strokeWidth={1} />}
|
||||
title="Your data, your rules"
|
||||
content={[
|
||||
'Complete control over your data. Export, delete, or manage it however you need.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-yellow-500">
|
||||
<FeatureContent
|
||||
icon={<WalletIcon className="size-8" strokeWidth={1} />}
|
||||
title="Affordably priced"
|
||||
content={[
|
||||
'Transparent pricing that scales with your needs. No hidden fees or surprise charges.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-orange-500">
|
||||
<FeatureContent
|
||||
icon={<ZapIcon className="size-8" strokeWidth={1} />}
|
||||
title="Moving fast"
|
||||
content={[
|
||||
'Regular updates and improvements. We move quickly to add features you need.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
|
||||
<FeatureContent
|
||||
icon={<ActivityIcon className="size-8" strokeWidth={1} />}
|
||||
title="Real-time data"
|
||||
content={[
|
||||
'See your analytics as they happen. No waiting for data processing or updates.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<Share2Icon className="size-8" strokeWidth={1} />}
|
||||
title="Sharable reports"
|
||||
content={[
|
||||
'Easily share insights with your team. Export and distribute reports with a single click.',
|
||||
<i key="soon">Coming soon</i>,
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-pink-500">
|
||||
<FeatureContent
|
||||
icon={<BarChart2Icon className="size-8" strokeWidth={1} />}
|
||||
title="Visualize your data"
|
||||
content={[
|
||||
'Beautiful, interactive visualizations that make your data easy to understand.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<LayersIcon className="size-8" strokeWidth={1} />}
|
||||
title="Best of both worlds"
|
||||
content={[
|
||||
'Combine the power of self-hosting with the convenience of cloud deployment.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
</div>
|
||||
|
||||
<Feature media={<EventsFeature />}>
|
||||
<FeatureContent
|
||||
title="Your events"
|
||||
content={[
|
||||
'Track every user interaction with powerful real-time event analytics. See all event properties, user actions, and conversion data in one place.',
|
||||
'From pageviews to custom events, get complete visibility into how users actually use your product.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
cols={1}
|
||||
className="mt-4"
|
||||
title="Some goodies"
|
||||
items={[
|
||||
'• Events arrive within seconds',
|
||||
'• Filter on any property or attribute',
|
||||
'• Get notified on important events',
|
||||
'• Export and analyze event data',
|
||||
'• Track user journeys and conversions',
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
<Feature reverse media={<ProfilesFeature />}>
|
||||
<FeatureContent
|
||||
title="Profiles and sessions"
|
||||
content={[
|
||||
'Get detailed insights into how users interact with your product through comprehensive profile and session tracking. See everything from basic metrics to detailed behavioral patterns.',
|
||||
'Track session duration, page views, and user journeys to understand how people actually use your product.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
cols={1}
|
||||
className="mt-4"
|
||||
title="What can you see?"
|
||||
items={[
|
||||
'• First and last seen dates',
|
||||
'• Session duration and counts',
|
||||
'• Page views and activity patterns',
|
||||
'• User location and device info',
|
||||
'• Browser and OS details',
|
||||
'• Event history and interactions',
|
||||
'• Real-time activity tracking',
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BellIcon,
|
||||
BookOpenIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
HeartIcon,
|
||||
LogOutIcon,
|
||||
MessageSquareIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
Share2Icon,
|
||||
ShoppingCartIcon,
|
||||
StarIcon,
|
||||
ThumbsUpIcon,
|
||||
UserPlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
action: string;
|
||||
location: string;
|
||||
platform: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const locations = [
|
||||
'Gothenburg',
|
||||
'Stockholm',
|
||||
'Oslo',
|
||||
'Copenhagen',
|
||||
'Berlin',
|
||||
'New York',
|
||||
'Singapore',
|
||||
'London',
|
||||
'Paris',
|
||||
'Madrid',
|
||||
'Rome',
|
||||
'Barcelona',
|
||||
'Amsterdam',
|
||||
'Vienna',
|
||||
];
|
||||
const platforms = ['iOS', 'Android', 'Windows', 'macOS'];
|
||||
const browsers = ['WebKit', 'Chrome', 'Firefox', 'Safari'];
|
||||
|
||||
const getCountryFlag = (country: (typeof locations)[number]) => {
|
||||
switch (country) {
|
||||
case 'Gothenburg':
|
||||
return '🇸🇪';
|
||||
case 'Stockholm':
|
||||
return '🇸🇪';
|
||||
case 'Oslo':
|
||||
return '🇳🇴';
|
||||
case 'Copenhagen':
|
||||
return '🇩🇰';
|
||||
case 'Berlin':
|
||||
return '🇩🇪';
|
||||
case 'New York':
|
||||
return '🇺🇸';
|
||||
case 'Singapore':
|
||||
return '🇸🇬';
|
||||
case 'London':
|
||||
return '🇬🇧';
|
||||
case 'Paris':
|
||||
return '🇫🇷';
|
||||
case 'Madrid':
|
||||
return '🇪🇸';
|
||||
case 'Rome':
|
||||
return '🇮🇹';
|
||||
case 'Barcelona':
|
||||
return '🇪🇸';
|
||||
case 'Amsterdam':
|
||||
return '🇳🇱';
|
||||
case 'Vienna':
|
||||
return '🇦🇹';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: (typeof platforms)[number]) => {
|
||||
switch (platform) {
|
||||
case 'iOS':
|
||||
return '🍎';
|
||||
case 'Android':
|
||||
return '🤖';
|
||||
case 'Windows':
|
||||
return '💻';
|
||||
case 'macOS':
|
||||
return '🍎';
|
||||
}
|
||||
};
|
||||
|
||||
const TOTAL_EVENTS = 10;
|
||||
|
||||
export function EventsFeature() {
|
||||
const [events, setEvents] = useState<Event[]>([
|
||||
{
|
||||
id: 1730663803358.4075,
|
||||
action: 'purchase',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: ShoppingCartIcon,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
id: 1730663801358.3079,
|
||||
action: 'logout',
|
||||
location: 'Copenhagen',
|
||||
platform: 'Windows',
|
||||
icon: LogOutIcon,
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
{
|
||||
id: 1730663799358.0283,
|
||||
action: 'sign up',
|
||||
location: 'Berlin',
|
||||
platform: 'Android',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 1730663797357.2036,
|
||||
action: 'share',
|
||||
location: 'Barcelona',
|
||||
platform: 'macOS',
|
||||
icon: Share2Icon,
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
id: 1730663795358.763,
|
||||
action: 'sign up',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 1730663792067.689,
|
||||
action: 'share',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: Share2Icon,
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
id: 1730663790075.3435,
|
||||
action: 'like',
|
||||
location: 'Copenhagen',
|
||||
platform: 'iOS',
|
||||
icon: HeartIcon,
|
||||
color: 'bg-pink-500',
|
||||
},
|
||||
{
|
||||
id: 1730663788070.351,
|
||||
action: 'recommend',
|
||||
location: 'Oslo',
|
||||
platform: 'Android',
|
||||
icon: ThumbsUpIcon,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
id: 1730663786074.429,
|
||||
action: 'read',
|
||||
location: 'New York',
|
||||
platform: 'Windows',
|
||||
icon: BookOpenIcon,
|
||||
color: 'bg-teal-500',
|
||||
},
|
||||
{
|
||||
id: 1730663784065.6309,
|
||||
action: 'sign up',
|
||||
location: 'Gothenburg',
|
||||
platform: 'iOS',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prepend new event every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
setEvents((prevEvents) => [
|
||||
generateEvent(),
|
||||
...prevEvents.slice(0, TOTAL_EVENTS - 1),
|
||||
]);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden p-8 max-h-[700px]">
|
||||
<div
|
||||
className="min-w-[500px] gap-4 flex flex-col overflow-hidden relative isolate"
|
||||
// style={{ height: 60 * TOTAL_EVENTS + 16 * (TOTAL_EVENTS - 1) }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{events.map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
className="flex items-center shadow bg-background-light rounded"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: '60px' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 50,
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-[200px] py-2 px-4">
|
||||
<div
|
||||
className={`size-8 rounded-full bg-background flex items-center justify-center ${event.color} text-white `}
|
||||
>
|
||||
{event.icon && <event.icon size={16} />}
|
||||
</div>
|
||||
<span className="font-medium truncate">{event.action}</span>
|
||||
</div>
|
||||
<div className="w-[150px] py-2 px-4 truncate">
|
||||
<span className="mr-2 text-xl relative top-px">
|
||||
{getCountryFlag(event.location)}
|
||||
</span>
|
||||
{event.location}
|
||||
</div>
|
||||
<div className="w-[150px] py-2 px-4 truncate">
|
||||
<span className="mr-2 text-xl relative top-px">
|
||||
{getPlatformIcon(event.platform)}
|
||||
</span>
|
||||
{event.platform}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to generate events (moved outside component)
|
||||
function generateEvent() {
|
||||
const actions = [
|
||||
{ text: 'sign up', icon: UserPlusIcon, color: 'bg-green-500' },
|
||||
{ text: 'purchase', icon: ShoppingCartIcon, color: 'bg-blue-500' },
|
||||
{ text: 'screen view', icon: EyeIcon, color: 'bg-purple-500' },
|
||||
{ text: 'logout', icon: LogOutIcon, color: 'bg-red-500' },
|
||||
{ text: 'like', icon: HeartIcon, color: 'bg-pink-500' },
|
||||
{ text: 'comment', icon: MessageSquareIcon, color: 'bg-indigo-500' },
|
||||
{ text: 'share', icon: Share2Icon, color: 'bg-cyan-500' },
|
||||
{ text: 'download', icon: DownloadIcon, color: 'bg-emerald-500' },
|
||||
{ text: 'notification', icon: BellIcon, color: 'bg-violet-500' },
|
||||
{ text: 'settings', icon: SettingsIcon, color: 'bg-slate-500' },
|
||||
{ text: 'search', icon: SearchIcon, color: 'bg-violet-500' },
|
||||
{ text: 'read', icon: BookOpenIcon, color: 'bg-teal-500' },
|
||||
{ text: 'recommend', icon: ThumbsUpIcon, color: 'bg-orange-500' },
|
||||
{ text: 'favorite', icon: StarIcon, color: 'bg-yellow-500' },
|
||||
];
|
||||
|
||||
const selectedAction = actions[Math.floor(Math.random() * actions.length)];
|
||||
|
||||
return {
|
||||
id: Date.now() + Math.random(),
|
||||
action: selectedAction.text,
|
||||
location: locations[Math.floor(Math.random() * locations.length)],
|
||||
platform: platforms[Math.floor(Math.random() * platforms.length)],
|
||||
icon: selectedAction.icon,
|
||||
color: selectedAction.color,
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Mock data structure for retention cohort
|
||||
const COHORT_DATA = [
|
||||
{
|
||||
week: 'Week 1',
|
||||
users: '2,543',
|
||||
retention: [100, 84, 73, 67, 62, 58],
|
||||
},
|
||||
{
|
||||
week: 'Week 2',
|
||||
users: '2,148',
|
||||
retention: [100, 80, 69, 63, 59, 55],
|
||||
},
|
||||
{
|
||||
week: 'Week 3',
|
||||
users: '1,958',
|
||||
retention: [100, 82, 71, 64, 60, 56],
|
||||
},
|
||||
{
|
||||
week: 'Week 4',
|
||||
users: '2,034',
|
||||
retention: [100, 83, 72, 65, 61, 57],
|
||||
},
|
||||
{
|
||||
week: 'Week 5',
|
||||
users: '1,987',
|
||||
retention: [100, 81, 70, 64, 60, 56],
|
||||
},
|
||||
{
|
||||
week: 'Week 6',
|
||||
users: '2,245',
|
||||
retention: [100, 85, 74, 68, 64, 60],
|
||||
},
|
||||
{
|
||||
week: 'Week 7',
|
||||
users: '2,108',
|
||||
retention: [100, 82, 71, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 8',
|
||||
users: '1,896',
|
||||
retention: [100, 83, 72, 66],
|
||||
},
|
||||
{
|
||||
week: 'Week 9',
|
||||
users: '2,156',
|
||||
retention: [100, 81, 70],
|
||||
},
|
||||
];
|
||||
const COHORT_DATA_ALT = [
|
||||
{
|
||||
week: 'Week 1',
|
||||
users: '2,876',
|
||||
retention: [100, 79, 76, 70, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 2',
|
||||
users: '2,543',
|
||||
retention: [100, 85, 73, 67, 62, 58],
|
||||
},
|
||||
{
|
||||
week: 'Week 3',
|
||||
users: '2,234',
|
||||
retention: [100, 79, 75, 68, 63, 59],
|
||||
},
|
||||
{
|
||||
week: 'Week 4',
|
||||
users: '2,456',
|
||||
retention: [100, 88, 77, 69, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 5',
|
||||
users: '2,321',
|
||||
retention: [100, 77, 73, 67, 54, 42],
|
||||
},
|
||||
{
|
||||
week: 'Week 6',
|
||||
users: '2,654',
|
||||
retention: [100, 91, 83, 69, 66, 62],
|
||||
},
|
||||
{
|
||||
week: 'Week 7',
|
||||
users: '2,432',
|
||||
retention: [100, 93, 88, 72, 64],
|
||||
},
|
||||
{
|
||||
week: 'Week 8',
|
||||
users: '2,123',
|
||||
retention: [100, 78, 76, 69],
|
||||
},
|
||||
{
|
||||
week: 'Week 9',
|
||||
users: '2,567',
|
||||
retention: [100, 70, 64],
|
||||
},
|
||||
];
|
||||
|
||||
function RetentionCell({ percentage }: { percentage: number }) {
|
||||
// Calculate color intensity based on percentage
|
||||
const getBackgroundColor = (value: number) => {
|
||||
if (value === 0) return 'bg-transparent';
|
||||
// Using CSS color mixing to create a gradient from light to dark blue
|
||||
return `rgb(${Math.round(239 - value * 1.39)} ${Math.round(246 - value * 1.46)} ${Math.round(255 - value * 0.55)})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-px text-sm font-medium w-[80px]">
|
||||
<div
|
||||
className="flex text-white items-center justify-center w-full h-full rounded"
|
||||
style={{
|
||||
backgroundColor: getBackgroundColor(percentage),
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
key={percentage}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{percentage}%
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductAnalyticsFeature() {
|
||||
const [currentData, setCurrentData] = useState(COHORT_DATA);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentData((current) =>
|
||||
current === COHORT_DATA ? COHORT_DATA_ALT : COHORT_DATA,
|
||||
);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* Header row */}
|
||||
<div className="min-w-[70px] flex flex-col">
|
||||
<div className="p-2 font-medium text-xs text-muted-foreground">
|
||||
Cohort
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Week numbers - changed length to 6 */}
|
||||
<div className="flex">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i.toString()}
|
||||
className="text-muted-foreground w-[80px] text-xs text-center p-2 font-medium"
|
||||
>
|
||||
W{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data rows */}
|
||||
<div className="flex flex-col">
|
||||
{currentData.map((cohort, rowIndex) => (
|
||||
<div key={rowIndex.toString()} className="flex">
|
||||
<div className="min-w-[70px] flex flex-col">
|
||||
<div className="p-2 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{cohort.week}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{cohort.retention.map((value, cellIndex) => (
|
||||
<RetentionCell key={cellIndex.toString()} percentage={value} />
|
||||
))}
|
||||
{/* Fill empty cells - changed length to 6 */}
|
||||
{Array.from({ length: 6 - cohort.retention.length }).map(
|
||||
(_, i) => (
|
||||
<div key={`empty-${i.toString()}`} className="w-[80px] p-px">
|
||||
<div className="h-full w-full rounded bg-background" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const PROFILES = [
|
||||
{
|
||||
name: 'Joe Bloggs',
|
||||
email: 'joe@bloggs.com',
|
||||
avatar: '/avatar.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 2 months',
|
||||
lastSeen: '41 minutes',
|
||||
sessions: '8',
|
||||
avgSession: '5m 59s',
|
||||
p90Session: '7m 42s',
|
||||
pageViews: '41',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@smith.com',
|
||||
avatar: '/avatar-2.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 1 month',
|
||||
lastSeen: '2 hours',
|
||||
sessions: '12',
|
||||
avgSession: '4m 32s',
|
||||
p90Session: '6m 15s',
|
||||
pageViews: '35',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Alex Johnson',
|
||||
email: 'alex@johnson.com',
|
||||
avatar: '/avatar-3.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 3 months',
|
||||
lastSeen: '15 minutes',
|
||||
sessions: '15',
|
||||
avgSession: '6m 20s',
|
||||
p90Session: '8m 10s',
|
||||
pageViews: '52',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function ProfilesFeature() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex === PROFILES.length) {
|
||||
setIsTransitioning(false);
|
||||
setCurrentIndex(0);
|
||||
setTimeout(() => setIsTransitioning(true), 50);
|
||||
} else {
|
||||
setCurrentIndex((current) => current + 1);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={`flex ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
|
||||
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{[...PROFILES, PROFILES[0]].map((profile, index) => (
|
||||
<div
|
||||
key={profile.name + index.toString()}
|
||||
className="w-full flex-shrink-0 p-8"
|
||||
>
|
||||
<div className="col md:row justify-center md:justify-start items-center gap-4">
|
||||
<Image
|
||||
src={profile.avatar}
|
||||
className="size-32 rounded-full"
|
||||
width={128}
|
||||
height={128}
|
||||
alt={profile.name}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-3xl font-semibold">{profile.name}</div>
|
||||
<div className="text-muted-foreground text-center md:text-left">
|
||||
{profile.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">First seen</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.firstSeen}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Last seen</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.lastSeen}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Sessions</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.sessions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Avg. Session
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.avgSession}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
P90. Session
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.p90Session}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Page views</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.pageViews}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRICING } from '@openpanel/payments/src/prices';
|
||||
import { CheckIcon, ChevronRightIcon, DollarSignIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { DoubleSwirl } from '../Swirls';
|
||||
import { Section, SectionHeader } from '../section';
|
||||
import { Tag } from '../tag';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
|
||||
export default Pricing;
|
||||
export function Pricing({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn(
|
||||
'overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground xl:rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DoubleSwirl className="absolute top-0 left-0" />
|
||||
<div className="container relative z-10 col">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag variant={'dark'}>
|
||||
<DollarSignIcon className="size-4" />
|
||||
Simple and predictable
|
||||
</Tag>
|
||||
}
|
||||
title="Simple pricing"
|
||||
description="Just pick how many events you want to track each month. No hidden costs."
|
||||
/>
|
||||
|
||||
<div className="grid self-center md:grid-cols-[200px_1fr] lg:grid-cols-[300px_1fr] gap-8">
|
||||
<div className="col gap-4">
|
||||
<h3 className="font-medium text-xl text-background/90 dark:text-foreground/90">
|
||||
Stop overpaying for features
|
||||
</h3>
|
||||
<ul className="gap-1 col text-background/70 dark:text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />
|
||||
Unlimited websites or apps
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited users
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited dashboards
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited charts
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited tracked profiles
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Yes, we have no limits or hidden costs
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
asChild
|
||||
className="self-start mt-4 px-8 group"
|
||||
>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started now
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col justify-between gap-4 max-w-lg">
|
||||
<div className="space-y-2">
|
||||
{PRICING.map((tier) => (
|
||||
<div
|
||||
key={tier.events}
|
||||
className={cn(
|
||||
'group col',
|
||||
'backdrop-blur-3xl bg-foreground/70 dark:bg-background-dark/70',
|
||||
'p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
|
||||
'mx-2',
|
||||
tier.discount &&
|
||||
'mx-0 px-6 py-3 !bg-emerald-900/20 hover:!bg-emerald-900/30',
|
||||
tier.popular &&
|
||||
'mx-0 px-6 py-3 !bg-orange-900/20 hover:!bg-orange-900/30',
|
||||
)}
|
||||
>
|
||||
<div className="row justify-between">
|
||||
<div>
|
||||
{new Intl.NumberFormat('en-US', {}).format(tier.events)}{' '}
|
||||
<span className="text-muted-foreground text-sm max-[420px]:hidden">
|
||||
events / month
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-4">
|
||||
{tier.popular && (
|
||||
<>
|
||||
<Tag variant="dark" className="hidden md:inline-flex">
|
||||
🔥 Popular
|
||||
</Tag>
|
||||
<span className="md:hidden">🔥</span>
|
||||
</>
|
||||
)}
|
||||
{tier.discount && (
|
||||
<>
|
||||
<Tag
|
||||
variant="dark"
|
||||
className="hidden md:inline-flex whitespace-nowrap"
|
||||
>
|
||||
💸 Discount
|
||||
</Tag>
|
||||
<span className="md:hidden">💸</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="row gap-1">
|
||||
{tier.discount && (
|
||||
<span className={cn('text-md font-semibold')}>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(tier.price * (1 - tier.discount.amount))}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-md font-semibold',
|
||||
tier.discount && 'line-through opacity-50',
|
||||
)}
|
||||
>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(tier.price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tier.discount && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Limited discount code available:{' '}
|
||||
<Tooltiper
|
||||
content={`Get ${tier.discount.amount * 100}% off your first year`}
|
||||
delayDuration={0}
|
||||
side="bottom"
|
||||
>
|
||||
<strong>{tier.discount.code}</strong>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'group',
|
||||
'row justify-between p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
|
||||
'mx-2',
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-nowrap">
|
||||
Over{' '}
|
||||
{new Intl.NumberFormat('en-US', {}).format(
|
||||
PRICING[PRICING.length - 1].events,
|
||||
)}
|
||||
</div>
|
||||
<div className="text-md font-semibold">
|
||||
<Link
|
||||
href="mailto:support@openpanel.dev"
|
||||
className="group-hover:underline"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-center text-sm text-muted-foreground mt-4 text-center max-w-[70%] w-full">
|
||||
<strong className="text-background/80 dark:text-foreground/80">
|
||||
All features are included upfront - no hidden costs.
|
||||
</strong>{' '}
|
||||
You choose how many events to track each month.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { type Framework, frameworks } from '@openpanel/sdk-info';
|
||||
import { CodeIcon, ShieldQuestionIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { HorizontalLine, PlusLine, VerticalLine } from '../line';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function Sdks() {
|
||||
return (
|
||||
<Section className="container overflow-hidden">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
|
||||
Easy to use
|
||||
</Tag>
|
||||
}
|
||||
title="SDKs"
|
||||
description="Use our modules to integrate with your favourite framework and start collecting events with ease. Enjoy quick and seamless setup."
|
||||
/>
|
||||
<div className="col gap-16">
|
||||
<div className="relative">
|
||||
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
|
||||
{frameworks.slice(0, 5).map((sdk, index) => (
|
||||
<SdkCard key={sdk.name} sdk={sdk} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<HorizontalLine className="opacity-40 -left-32 -right-32" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
|
||||
{frameworks.slice(5, 10).map((sdk, index) => (
|
||||
<SdkCard key={sdk.name} sdk={sdk} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<HorizontalLine className="opacity-40 -left-32 -right-32" />
|
||||
</div>
|
||||
|
||||
<div className="center-center gap-2 col">
|
||||
<h3 className="text-muted-foreground text-sm">And many more!</h3>
|
||||
<Button asChild>
|
||||
<Link href="/docs">Read our docs</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SdkCard({
|
||||
sdk,
|
||||
index,
|
||||
}: {
|
||||
sdk: Framework;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
key={sdk.name}
|
||||
href={sdk.href}
|
||||
className="group relative z-10 col gap-2 uppercase center-center aspect-video bg-background-light rounded-lg shadow-[inset_0_0_0_1px_theme(colors.border),0_0_30px_0px_hsl(var(--border)/0.5)] transition-all hover:scale-105 hover:bg-background-dark"
|
||||
>
|
||||
{index === 0 && <PlusLine className="opacity-30 top-0 left-0" />}
|
||||
{index === 2 && <PlusLine className="opacity-80 bottom-0 right-0" />}
|
||||
<VerticalLine className="left-0 opacity-40" />
|
||||
<VerticalLine className="right-0 opacity-40" />
|
||||
<div className="absolute inset-0 center-center overflow-hidden opacity-20">
|
||||
<sdk.IconComponent className="size-32 top-[33%] relative group-hover:top-[30%] group-hover:scale-105 transition-all" />
|
||||
</div>
|
||||
<div
|
||||
className="center-center gap-1 col w-full h-full relative rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, hsl(var(--background)) 0%, hsl(var(--background)/0.7) 100%)',
|
||||
}}
|
||||
>
|
||||
<sdk.IconComponent className="size-8" />
|
||||
{/* <h4 className="text-muted-foreground text-[10px]">{sdk.name}</h4> */}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { VerticalLine } from '../line';
|
||||
import { PlusLine } from '../line';
|
||||
import { HorizontalLine } from '../line';
|
||||
import { Section } from '../section';
|
||||
import { Button } from '../ui/button';
|
||||
import { WorldMap } from '../world-map';
|
||||
|
||||
function shortNumber(num: number) {
|
||||
if (num < 1e3) return num;
|
||||
if (num >= 1e3 && num < 1e6) return `${+(num / 1e3).toFixed(1)}K`;
|
||||
if (num >= 1e6 && num < 1e9) return `${+(num / 1e6).toFixed(1)}M`;
|
||||
if (num >= 1e9 && num < 1e12) return `${+(num / 1e9).toFixed(1)}B`;
|
||||
if (num >= 1e12) return `${+(num / 1e12).toFixed(1)}T`;
|
||||
}
|
||||
|
||||
export async function Stats() {
|
||||
const { projectsCount, eventsCount, eventsLast24hCount } = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/misc/stats`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.catch(() => ({
|
||||
projectsCount: 0,
|
||||
eventsCount: 0,
|
||||
eventsLast24hCount: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<StatsPure
|
||||
projectCount={projectsCount}
|
||||
eventCount={eventsCount}
|
||||
last24hCount={eventsLast24hCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPure({
|
||||
projectCount,
|
||||
eventCount,
|
||||
last24hCount,
|
||||
}: { projectCount: number; eventCount: number; last24hCount: number }) {
|
||||
return (
|
||||
<Section className="bg-gradient-to-b from-background via-background-dark to-background-dark py-64 pt-44 relative overflow-hidden -mt-16">
|
||||
{/* Map */}
|
||||
<div className="absolute inset-0 -top-20 center-center items-start select-none opacity-10">
|
||||
<div className="min-w-[1400px] w-full">
|
||||
<WorldMap />
|
||||
{/* Gradient over Map */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<HorizontalLine />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 container center-center">
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<PlusLine className="hidden lg:block top-0 left-0" />
|
||||
<div className="text-muted-foreground text-xs">Active projects</div>
|
||||
<div className="text-5xl font-bold font-mono">{projectCount}</div>
|
||||
</div>
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<div className="text-muted-foreground text-xs">Total events</div>
|
||||
<div className="text-5xl font-bold font-mono">
|
||||
{shortNumber(eventCount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<VerticalLine className="hidden lg:block right-0" />
|
||||
<PlusLine className="hidden lg:block bottom-0 left-0" />
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Events last 24 h
|
||||
</div>
|
||||
<div className="text-5xl font-bold font-mono">
|
||||
{shortNumber(last24hCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalLine />
|
||||
</div>
|
||||
<div className="center-center col gap-4 absolute bottom-20 left-0 right-0 z-10">
|
||||
<p>Get the analytics you deserve</p>
|
||||
<Button asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { TwitterCard } from '@/components/twitter-card';
|
||||
import { MessageCircleIcon } from 'lucide-react';
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-steven.jpg',
|
||||
name: 'Steven Tey',
|
||||
handle: 'steventey',
|
||||
content: [
|
||||
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
|
||||
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
|
||||
'Built by @CarlLindesvard and it’s already tracking 750K+ events 🤩',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-pontus.jpg',
|
||||
name: 'Pontus Abrahamsson — oss/acc',
|
||||
handle: 'pontusab',
|
||||
content: ['Thanks, OpenPanel is a beast, love it!'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-piotr.jpg',
|
||||
name: 'Piotr Kulpinski',
|
||||
handle: 'piotrkulpinski',
|
||||
content: [
|
||||
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
|
||||
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-greg.png',
|
||||
name: 'greg hodson 🍜',
|
||||
handle: 'h0dson',
|
||||
content: ['i second this, openpanel is killing it'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-jacob.jpg',
|
||||
name: 'Jacob 🍀 Build in Public',
|
||||
handle: 'javayhuwx',
|
||||
content: [
|
||||
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
|
||||
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
|
||||
'#buildinpublic #indiehackers',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-lee.jpg',
|
||||
name: 'Lee',
|
||||
handle: 'DutchEngIishman',
|
||||
content: [
|
||||
'Day two of marketing.',
|
||||
'I like this upward trend..',
|
||||
'P.S. website went live on Sunday',
|
||||
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
];
|
||||
|
||||
export default Testimonials;
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<MessageCircleIcon className="size-4" strokeWidth={1.5} />
|
||||
Testimonials
|
||||
</Tag>
|
||||
}
|
||||
title="What people say"
|
||||
description="What our customers say about us."
|
||||
/>
|
||||
<div className="col md:row gap-4">
|
||||
<div className="col gap-4 flex-1">
|
||||
{testimonials.slice(0, testimonials.length / 2).map((testimonial) => (
|
||||
<TwitterCard key={testimonial.handle} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
<div className="col gap-4 flex-1">
|
||||
{testimonials.slice(testimonials.length / 2).map((testimonial) => (
|
||||
<TwitterCard key={testimonial.handle} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Section, SectionHeader } from './section';
|
||||
import { Tag } from './tag';
|
||||
import { Tooltip } from './ui/tooltip';
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: 'Helpy UI',
|
||||
url: 'https://helpy-ui.com',
|
||||
logo: 'helpy-ui.png',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'KiddoKitchen',
|
||||
url: 'https://kiddokitchen.se',
|
||||
logo: 'kiddokitchen.png',
|
||||
border: false,
|
||||
},
|
||||
{
|
||||
name: 'Maneken',
|
||||
url: 'https://maneken.app',
|
||||
logo: 'maneken.jpg',
|
||||
border: false,
|
||||
},
|
||||
{
|
||||
name: 'Midday',
|
||||
url: 'https://midday.ai',
|
||||
logo: 'midday.png',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'Screenzen',
|
||||
url: 'https://www.screenzen.co',
|
||||
logo: 'screenzen.avif',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'Tiptip',
|
||||
url: 'https://tiptip.id',
|
||||
logo: 'tiptip.jpg',
|
||||
border: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyOpenPanel() {
|
||||
return (
|
||||
<div className="bg-background-light my-12 col">
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
title="Why OpenPanel?"
|
||||
description="We built OpenPanel to get the best of both web and product analytics. With that in mind we have created a simple but very powerful platform that can handle most companies needs."
|
||||
/>
|
||||
<div className="center-center col gap-4 -mt-4">
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
With 2000+ registered projects
|
||||
</Tag>
|
||||
<div className="row gap-4 justify-center flex-wrap">
|
||||
{images.map((image) => (
|
||||
<a
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
key={image.logo}
|
||||
className={cn(
|
||||
'group rounded-lg bg-white center-center size-20 hover:scale-110 transition-all duration-300',
|
||||
image.border && 'p-2 border border-border shadow-sm',
|
||||
)}
|
||||
title={image.name}
|
||||
>
|
||||
<Image
|
||||
src={`/logos/${image.logo}`}
|
||||
alt={image.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg grayscale group-hover:grayscale-0 transition-all duration-300"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
import DottedMap from 'dotted-map/without-countries';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { mapJsonString } from './world-map-string';
|
||||
|
||||
// Static coordinates list with 50 points
|
||||
const COORDINATES = [
|
||||
// Western Hemisphere (Focused on West Coast)
|
||||
{ lat: 47.6062, lng: -122.3321 }, // Seattle, USA
|
||||
{ lat: 45.5155, lng: -122.6789 }, // Portland, USA
|
||||
{ lat: 37.7749, lng: -122.4194 }, // San Francisco, USA
|
||||
{ lat: 34.0522, lng: -118.2437 }, // Los Angeles, USA
|
||||
{ lat: 32.7157, lng: -117.1611 }, // San Diego, USA
|
||||
{ lat: 49.2827, lng: -123.1207 }, // Vancouver, Canada
|
||||
{ lat: 58.3019, lng: -134.4197 }, // Juneau, Alaska
|
||||
{ lat: 61.2181, lng: -149.9003 }, // Anchorage, Alaska
|
||||
{ lat: 64.8378, lng: -147.7164 }, // Fairbanks, Alaska
|
||||
{ lat: 71.2906, lng: -156.7886 }, // Utqiaġvik (Barrow), Alaska
|
||||
{ lat: 60.5544, lng: -151.2583 }, // Kenai, Alaska
|
||||
{ lat: 61.5815, lng: -149.444 }, // Wasilla, Alaska
|
||||
{ lat: 66.1666, lng: -153.3707 }, // Bettles, Alaska
|
||||
{ lat: 63.8659, lng: -145.637 }, // Delta Junction, Alaska
|
||||
{ lat: 55.3422, lng: -131.6461 }, // Ketchikan, Alaska
|
||||
|
||||
// Eastern Hemisphere (Focused on East Asia)
|
||||
{ lat: 35.6762, lng: 139.6503 }, // Tokyo, Japan
|
||||
{ lat: 43.0621, lng: 141.3544 }, // Sapporo, Japan
|
||||
{ lat: 26.2286, lng: 127.6809 }, // Naha, Japan
|
||||
{ lat: 31.2304, lng: 121.4737 }, // Shanghai, China
|
||||
{ lat: 22.3193, lng: 114.1694 }, // Hong Kong
|
||||
{ lat: 37.5665, lng: 126.978 }, // Seoul, South Korea
|
||||
{ lat: 25.033, lng: 121.5654 }, // Taipei, Taiwan
|
||||
|
||||
// Russian Far East
|
||||
{ lat: 64.7336, lng: 177.5169 }, // Anadyr, Russia
|
||||
{ lat: 59.5613, lng: 150.8086 }, // Magadan, Russia
|
||||
{ lat: 43.1332, lng: 131.9113 }, // Vladivostok, Russia
|
||||
{ lat: 53.0444, lng: 158.6478 }, // Petropavlovsk-Kamchatsky, Russia
|
||||
{ lat: 62.0355, lng: 129.6755 }, // Yakutsk, Russia
|
||||
{ lat: 48.4827, lng: 135.0846 }, // Khabarovsk, Russia
|
||||
{ lat: 46.9589, lng: 142.7319 }, // Yuzhno-Sakhalinsk, Russia
|
||||
{ lat: 52.9651, lng: 158.2728 }, // Yelizovo, Russia
|
||||
{ lat: 56.1304, lng: 101.614 }, // Bratsk, Russia
|
||||
|
||||
// Australia & New Zealand (Main Cities)
|
||||
{ lat: -33.8688, lng: 151.2093 }, // Sydney, Australia
|
||||
{ lat: -37.8136, lng: 144.9631 }, // Melbourne, Australia
|
||||
{ lat: -27.4698, lng: 153.0251 }, // Brisbane, Australia
|
||||
{ lat: -31.9505, lng: 115.8605 }, // Perth, Australia
|
||||
{ lat: -12.4634, lng: 130.8456 }, // Darwin, Australia
|
||||
{ lat: -34.9285, lng: 138.6007 }, // Adelaide, Australia
|
||||
{ lat: -42.8821, lng: 147.3272 }, // Hobart, Australia
|
||||
{ lat: -16.9186, lng: 145.7781 }, // Cairns, Australia
|
||||
{ lat: -23.7041, lng: 133.8814 }, // Alice Springs, Australia
|
||||
{ lat: -41.2865, lng: 174.7762 }, // Wellington, New Zealand
|
||||
{ lat: -36.8485, lng: 174.7633 }, // Auckland, New Zealand
|
||||
{ lat: -43.532, lng: 172.6306 }, // Christchurch, New Zealand
|
||||
];
|
||||
|
||||
const getRandomCoordinates = (count: number) => {
|
||||
const shuffled = [...COORDINATES].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
};
|
||||
|
||||
export function WorldMap() {
|
||||
const [visiblePins, setVisiblePins] = useState<typeof COORDINATES>([
|
||||
{ lat: 61.2181, lng: -149.9003 },
|
||||
{ lat: 31.2304, lng: 121.4737 },
|
||||
{ lat: 59.5613, lng: 150.8086 },
|
||||
{ lat: 64.8378, lng: -147.7164 },
|
||||
{ lat: -33.8688, lng: 151.2093 },
|
||||
{ lat: 43.0621, lng: 141.3544 },
|
||||
{ lat: 58.3019, lng: -134.4197 },
|
||||
{ lat: 37.5665, lng: 126.978 },
|
||||
{ lat: -41.2865, lng: 174.7762 },
|
||||
{ lat: -36.8485, lng: 174.7633 },
|
||||
{ lat: -31.9505, lng: 115.8605 },
|
||||
{ lat: 35.6762, lng: 139.6503 },
|
||||
{ lat: 49.2827, lng: -123.1207 },
|
||||
{ lat: -12.4634, lng: 130.8456 },
|
||||
{ lat: 56.1304, lng: 101.614 },
|
||||
{ lat: 22.3193, lng: 114.1694 },
|
||||
{ lat: 55.3422, lng: -131.6461 },
|
||||
{ lat: 32.7157, lng: -117.1611 },
|
||||
{ lat: 61.5815, lng: -149.444 },
|
||||
{ lat: 60.5544, lng: -151.2583 },
|
||||
]);
|
||||
const activePinColor = '#2265EC';
|
||||
const inactivePinColor = '#818181';
|
||||
const visiblePinsCount = 20;
|
||||
|
||||
// Helper function to update pins
|
||||
const updatePins = () => {
|
||||
setVisiblePins((current) => {
|
||||
const newPins = [...current];
|
||||
// Remove 2 random pins
|
||||
const pinsToAdd = 4;
|
||||
if (newPins.length >= pinsToAdd) {
|
||||
for (let i = 0; i < pinsToAdd; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * newPins.length);
|
||||
newPins.splice(randomIndex, 1);
|
||||
}
|
||||
}
|
||||
// Add 2 new random pins from the main coordinates
|
||||
const availablePins = COORDINATES.filter(
|
||||
(coord) =>
|
||||
!newPins.some(
|
||||
(pin) => pin.lat === coord.lat && pin.lng === coord.lng,
|
||||
),
|
||||
);
|
||||
const newRandomPins = availablePins
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, pinsToAdd);
|
||||
return [...newPins, ...newRandomPins].slice(0, visiblePinsCount);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update pins every 4 seconds
|
||||
const interval = setInterval(updatePins, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const map = useMemo(() => {
|
||||
const map = new DottedMap({ map: mapJsonString as any });
|
||||
|
||||
visiblePins.forEach((coord) => {
|
||||
map.addPin({
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
svgOptions: { color: activePinColor, radius: 0.3 },
|
||||
});
|
||||
});
|
||||
|
||||
return map.getSVG({
|
||||
radius: 0.2,
|
||||
color: inactivePinColor,
|
||||
shape: 'circle',
|
||||
});
|
||||
}, [visiblePins]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img
|
||||
loading="lazy"
|
||||
alt="World map with active users"
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(map)}`}
|
||||
className="object-contain w-full h-full"
|
||||
width={1200}
|
||||
height={630}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
title: Find an alternative to Mixpanel
|
||||
description: A list of alternatives to Mixpanel, including open source and paid options.
|
||||
date: 2024-11-12
|
||||
updated: 2025-12-02
|
||||
team: OpenPanel Team
|
||||
tag: Comparison
|
||||
cover: /content/cover-alternatives.jpg
|
||||
@@ -42,11 +43,13 @@ Its dashboard shows real-time data clearly, helping teams make better decisions.
|
||||
|
||||
Mixpanel remains a strong player in analytics, helping businesses improve their online presence.
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Mixpanel](/compare/mixpanel-alternative)
|
||||
|
||||
## Limitations of Mixpanel
|
||||
|
||||
Despite its strengths, Mixpanel has several problems users need to deal with.
|
||||
|
||||
First, Mixpanel's pricing is often too high. The cost of all its features may not make sense for smaller companies or startups, making it hard for growing businesses to use. Simply put, you might not get enough value for what you pay.
|
||||
First, [Mixpanel's pricing](/articles/mixpanel-pricing) is often too high. The cost of all its features may not make sense for smaller companies or startups, making it hard for growing businesses to use. Simply put, you might not get enough value for what you pay.
|
||||
|
||||
Second, Mixpanel is hard to learn. New users often struggle with its complex interface.
|
||||
|
||||
@@ -177,4 +180,8 @@ With new privacy laws like GDPR and CCPA, companies are finding new ways to get
|
||||
**Quick data updates**
|
||||
Getting data quickly helps businesses make faster, better decisions.
|
||||
|
||||
By using these new tools, businesses can better understand their users and do better online.
|
||||
By using these new tools, businesses can better understand their users and do better online.
|
||||
|
||||
## Related Articles
|
||||
|
||||
Looking for more options? Read our comprehensive guide on [9 best open source web analytics tools](/articles/open-source-web-analytics).
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: Better compliance with self-hosted analytics
|
||||
description: A practical guide to GDPR, CCPA, HIPAA, and other privacy regulations for analytics. Learn how OpenPanel and self-hosting can simplify compliance.
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-12-08
|
||||
cover: /content/compliance.jpg
|
||||
---
|
||||
|
||||
Privacy regulations are everywhere now. GDPR in Europe, CCPA in California, HIPAA for healthcare, and the list keeps growing. If you're running a website or app, you've probably wondered: "Am I actually compliant with all this stuff?"
|
||||
|
||||
The good news? Analytics compliance doesn't have to be complicated or expensive. The bad news? Most traditional analytics tools make it way harder than it needs to be.
|
||||
|
||||
In this guide, we'll break down the major compliance frameworks, explain what they actually mean for your analytics setup, and show you how [OpenPanel](/) can help you stay compliant without the headache.
|
||||
|
||||
## Why Analytics Compliance Matters
|
||||
|
||||
Let's start with the basics. When someone visits your website, you're collecting data about them. Maybe it's their location, what pages they viewed, how long they stayed, or what buttons they clicked. Under most privacy laws, this counts as personal data.
|
||||
|
||||
The consequences of getting compliance wrong are real. GDPR fines can reach €20 million or 4% of global revenue, whichever is higher. CCPA violations cost up to $7,988 per intentional violation. And beyond the fines, there's the reputation damage and loss of customer trust.
|
||||
|
||||
Here's the thing though: most compliance issues with analytics come down to a few core problems.
|
||||
|
||||
**Third-party data sharing.** When you use Google Analytics or similar tools, your visitors' data flows through their servers. That creates a chain of custody problem. You're responsible for what happens to that data, even when it's sitting on someone else's infrastructure.
|
||||
|
||||
**Cookies and consent.** Traditional analytics tools rely heavily on cookies. Under GDPR, PECR, and similar regulations, you need explicit consent before dropping most cookies. That means cookie banners, consent management, and all the friction that comes with it.
|
||||
|
||||
**International data transfers.** If you're collecting data from EU residents and it ends up on US servers, you've got a potential compliance issue. This is exactly why Google Analytics has been ruled illegal in several EU countries.
|
||||
|
||||
The solution? Either use a [privacy-first analytics tool](/articles/cookieless-analytics) that sidesteps these issues, or self-host your analytics so data never leaves your infrastructure.
|
||||
|
||||
## GDPR: The One Everyone Knows About
|
||||
|
||||
The General Data Protection Regulation is the big one. It applies to any organization that processes personal data of EU residents, regardless of where that organization is based. So if you have visitors from Europe, GDPR applies to you.
|
||||
|
||||
### What GDPR Requires for Analytics
|
||||
|
||||
GDPR is built around a few key principles that directly impact how you can do analytics.
|
||||
|
||||
**Lawful basis for processing.** You need a legal reason to collect and process personal data. For analytics, this usually means either getting consent or demonstrating "legitimate interest." Consent is cleaner but requires those annoying cookie banners. Legitimate interest is possible but requires documentation and balancing tests.
|
||||
|
||||
**Data minimization.** Only collect what you actually need. If you're tracking 50 different user properties but only looking at 5 of them, you've got a problem.
|
||||
|
||||
**Right to erasure.** Users can request that you delete their data. You need to be able to actually do this, which is tricky when your data is sitting in a third-party's database.
|
||||
|
||||
**Transparency.** Users need to know what you're collecting and why. This means clear privacy policies and, in most cases, cookie consent interfaces.
|
||||
|
||||
### Why Google Analytics Keeps Getting Banned
|
||||
|
||||
Google Analytics has been declared non-compliant with GDPR by data protection authorities in Austria, France, Italy, and other EU countries. The core issue is that GA transfers personal data (including IP addresses) to US servers, where it can potentially be accessed by US intelligence agencies. This violates Chapter V of GDPR, which governs international data transfers.
|
||||
|
||||
Even with IP anonymization enabled, the data still hits Google's servers before being anonymized. That's a problem.
|
||||
|
||||
<WindowImage
|
||||
srcDark="/screenshots/overview-dark.webp"
|
||||
srcLight="/screenshots/overview-light.webp"
|
||||
alt="OpenPanel Dashboard Overview"
|
||||
caption="This is how OpenPanel dashboard looks like, the self-hosting version has all features that our cloud version has. The release lifecycle is 2-3 months behind cloud version."
|
||||
/>
|
||||
|
||||
### How OpenPanel Handles GDPR
|
||||
|
||||
OpenPanel takes a different approach. We built it with privacy as the foundation, not an afterthought.
|
||||
|
||||
**Cookieless by default.** OpenPanel doesn't use cookies for tracking. No cookies means no cookie consent banners required for basic analytics. Your visitors get a cleaner experience, and you avoid the consent management complexity. Learn more about how this works in our [cookieless analytics guide](/articles/cookieless-analytics).
|
||||
|
||||
**No third-party data sharing.** With OpenPanel Cloud, your data stays in our EU-based infrastructure. With [self-hosting](/articles/how-to-self-host-openpanel), data never leaves your servers at all.
|
||||
|
||||
**Built-in data export and deletion.** Need to handle a data subject request? OpenPanel's [Export API](/docs/api/export) makes it straightforward to export user data. You can delete your entire project's data through the dashboard, and if you need to delete a specific identified profile, you can request that from us.
|
||||
|
||||
**Transparent and open source.** You can [audit the code yourself](https://github.com/Openpanel-dev/openpanel) to see exactly what's being collected and how it's processed.
|
||||
|
||||
## CCPA: California's Privacy Law
|
||||
|
||||
The California Consumer Privacy Act (and its amendment, CPRA) gives California residents specific rights over their personal information. If you do business in California or collect data from California residents, this one matters.
|
||||
|
||||
### Key CCPA Requirements
|
||||
|
||||
**Right to know.** Consumers can ask what personal information you've collected about them, where it came from, and who you've shared it with.
|
||||
|
||||
**Right to delete.** Similar to GDPR, consumers can request deletion of their personal information.
|
||||
|
||||
**Right to opt-out.** Here's the big one for analytics. Consumers can opt out of the "sale" or "sharing" of their personal information. And under CCPA, "sharing" includes providing data to third parties for cross-context behavioral advertising, which is exactly what many analytics tools do.
|
||||
|
||||
**No discrimination.** You can't treat consumers differently because they exercised their privacy rights.
|
||||
|
||||
### The "Do Not Sell" Problem
|
||||
|
||||
Many traditional analytics tools technically "share" your user data with third parties. When you use Google Analytics, user data flows through Google's systems and can be used for their own purposes. Under CCPA, this could be considered sharing, which means you need to honor "Do Not Sell or Share" requests.
|
||||
|
||||
This creates a real operational burden. You need systems to track opt-out requests, communicate them to all your vendors, and verify compliance.
|
||||
|
||||
### How OpenPanel Simplifies CCPA
|
||||
|
||||
With OpenPanel, there's no sharing to opt out of.
|
||||
|
||||
When you use OpenPanel Cloud, your data is processed solely for your analytics purposes. We don't sell or share your data with anyone. When you [self-host OpenPanel](/docs/self-hosting/self-hosting), you control the entire data pipeline. There's no third party involved at all.
|
||||
|
||||
This architectural difference eliminates most CCPA complexity. You still need proper privacy disclosures, but you don't need to worry about vendor management for your analytics data.
|
||||
|
||||
## HIPAA: Healthcare's Special Rules
|
||||
|
||||
If you're in healthcare or handle Protected Health Information (PHI), HIPAA adds another layer of compliance requirements. This is where things get expensive with traditional analytics providers.
|
||||
|
||||
### The BAA Requirement
|
||||
|
||||
HIPAA requires that any third party with access to PHI must sign a Business Associate Agreement (BAA). This is a legal contract that establishes what the vendor can and can't do with health information.
|
||||
|
||||
The problem? Most analytics providers either don't offer BAAs at all, or charge significant premiums for them. We're talking enterprise-tier pricing that can run into tens of thousands of dollars annually.
|
||||
|
||||
Google Analytics doesn't offer a BAA. Mixpanel does, but only on enterprise plans. The same goes for most major analytics platforms.
|
||||
|
||||
### What Counts as PHI in Analytics
|
||||
|
||||
This is where many healthcare organizations get tripped up. PHI isn't just medical records. Under HHS guidance, when someone visits a healthcare website's authenticated pages, their IP address combined with the fact that they're viewing health-related content can constitute PHI.
|
||||
|
||||
This means that if you're using cookie-based tracking on a patient portal or healthcare app, you might be sharing PHI with your analytics provider without realizing it.
|
||||
|
||||
### The Self-Hosting Solution
|
||||
|
||||
Here's where self-hosting completely changes the equation: if you host your own analytics, you don't need a BAA.
|
||||
|
||||
Think about it. A BAA is required when you're sharing PHI with a business associate. But if you [self-host OpenPanel](/articles/how-to-self-host-openpanel) on your own HIPAA-compliant infrastructure, there's no third party involved. The data never leaves your environment. There's no business associate relationship to manage.
|
||||
|
||||
This approach lets you get meaningful analytics from your healthcare applications without the enterprise pricing or legal complexity. You deploy OpenPanel on your existing HIPAA-compliant servers using [Docker Compose](/docs/self-hosting/deploy-docker-compose), [Kubernetes](/docs/self-hosting/deploy-kubernetes), or your preferred deployment method, and you're done.
|
||||
|
||||
## PECR: The UK's Cookie Law
|
||||
|
||||
If you have visitors from the UK, you need to think about PECR (Privacy and Electronic Communications Regulations) alongside UK GDPR. PECR specifically regulates cookies and similar tracking technologies.
|
||||
|
||||
### What PECR Requires
|
||||
|
||||
PECR has a simple but strict rule: you need consent before storing or accessing information on a user's device. This includes cookies, local storage, and similar technologies.
|
||||
|
||||
There are only two exemptions. The "communication exemption" covers technologies essential for transmitting a communication. The "strictly necessary exemption" covers technologies essential for providing a service the user explicitly requested.
|
||||
|
||||
Here's the important part: **analytics cookies are not exempt.** The UK's Information Commissioner's Office has been clear about this. If you're using cookie-based analytics, you need consent.
|
||||
|
||||
### Fines Are Increasing
|
||||
|
||||
PECR fines used to be capped at £500,000. The new Data (Use and Access) Act aligns PECR penalties with UK GDPR, meaning potential fines of up to £17.5 million. The ICO has also been increasingly active in enforcing cookie compliance.
|
||||
|
||||
### Cookieless Analytics Bypasses PECR
|
||||
|
||||
Since [OpenPanel's tracking is cookieless](/articles/cookieless-analytics), the PECR consent requirement simply doesn't apply to basic analytics. You're not storing anything on the user's device, so there's nothing to consent to.
|
||||
|
||||
This doesn't mean you can track whatever you want. UK GDPR still applies to the processing of personal data. But it does mean you can skip the cookie banners and consent management platforms that PECR would otherwise require.
|
||||
|
||||
## The Self-Hosting Advantage
|
||||
|
||||
We've mentioned self-hosting several times now, and for good reason. It's the single most effective way to simplify analytics compliance across almost every framework.
|
||||
|
||||
### What Self-Hosting Actually Means
|
||||
|
||||
When you self-host OpenPanel, you run the entire analytics platform on your own infrastructure. This could be your own servers, your cloud account (AWS, GCP, Azure, etc.), or even a simple VPS.
|
||||
|
||||
The data flow is completely different from traditional analytics.
|
||||
|
||||
**Traditional analytics:** User → Your website → Analytics provider's servers → Provider dashboard
|
||||
|
||||
**Self-hosted analytics:** User → Your website → Your servers → Your dashboard
|
||||
|
||||
That middle step makes all the difference. With traditional analytics, you're sharing data with a third party. With self-hosting, data never leaves your control.
|
||||
|
||||
### Compliance Benefits Across Frameworks
|
||||
|
||||
**GDPR:** No international data transfers if you host in the EU. Full control over data retention and deletion. No third-party data sharing to manage.
|
||||
|
||||
**CCPA:** No "selling" or "sharing" by definition. You're not providing data to any third party.
|
||||
|
||||
**HIPAA:** No BAA required because there's no business associate. PHI stays within your HIPAA-compliant environment.
|
||||
|
||||
**PECR:** Cookieless tracking means no consent requirements for basic analytics.
|
||||
|
||||
**SOC 2:** Easier vendor risk management when you control the analytics infrastructure. Your existing security controls apply.
|
||||
|
||||
### Beyond Compliance
|
||||
|
||||
Self-hosting isn't just about compliance. There are real practical benefits too.
|
||||
|
||||
**Cost predictability.** No per-event pricing surprises. Your costs are your server costs, which are typically much lower than SaaS analytics pricing at scale.
|
||||
|
||||
**No vendor lock-in.** Your data is in your database. You can query it however you want, integrate it with other systems, or migrate away anytime.
|
||||
|
||||
**Performance.** Data stays close to your users. No external requests that might get blocked by ad blockers.
|
||||
|
||||
**Full transparency.** OpenPanel is [open source](https://github.com/Openpanel-dev/openpanel). You can audit exactly what's being collected and how.
|
||||
|
||||
### Getting Started with Self-Hosting
|
||||
|
||||
We've tried to make self-hosting as simple as possible. The basic process is:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openpanel-dev/openpanel.git
|
||||
cd openpanel/self-hosting
|
||||
./setup
|
||||
./start
|
||||
```
|
||||
|
||||
We have detailed guides for different deployment options including [Docker Compose](/docs/self-hosting/deploy-docker-compose), [Coolify](/docs/self-hosting/deploy-coolify), [Dokploy](/docs/self-hosting/deploy-dokploy), and [Kubernetes](/docs/self-hosting/deploy-kubernetes).
|
||||
|
||||
Check out our full [self-hosting guide](/articles/how-to-self-host-openpanel) for a walkthrough of the entire process.
|
||||
|
||||
## The Hidden Cost of "Free" Analytics
|
||||
|
||||
Let's talk about Google Analytics for a moment. It's free, which is great. But that "free" comes with significant compliance costs that most organizations don't account for.
|
||||
|
||||
**Cookie consent management.** You need a consent management platform, ongoing maintenance, and likely degraded data quality from users who opt out.
|
||||
|
||||
**Privacy policy and legal review.** Your lawyers need to review how GA processes data and update your privacy documentation accordingly.
|
||||
|
||||
**Vendor assessment overhead.** For regulated industries, you need to continuously assess Google's practices and compliance posture.
|
||||
|
||||
**GDPR risk.** Given the ongoing regulatory actions against GA in Europe, you're taking on legal risk that's hard to quantify.
|
||||
|
||||
**Data subject requests.** Handling deletion requests through GA's tools is cumbersome and incomplete.
|
||||
|
||||
When you add up these costs, "free" analytics often isn't free at all. A transparent, paid solution like [OpenPanel](/pricing) or a self-hosted setup frequently works out cheaper while being more compliant.
|
||||
|
||||
## Other Regulations Worth Knowing
|
||||
|
||||
While GDPR, CCPA, HIPAA, and PECR are the big ones, there are others depending on your audience.
|
||||
|
||||
**LGPD (Brazil):** Similar to GDPR, with requirements for consent, data minimization, and user rights.
|
||||
|
||||
**PIPEDA (Canada):** Requires consent for collection and use of personal information, with some exceptions.
|
||||
|
||||
**US State Laws:** Over 20 US states now have comprehensive privacy laws, including Virginia, Colorado, Connecticut, and more. Most follow patterns similar to CCPA.
|
||||
|
||||
The good news is that if you're compliant with GDPR and CCPA, you're probably in good shape for most of these. And if you're using cookieless, self-hosted analytics, you're ahead of the game for all of them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Ready to simplify your analytics compliance? You have two paths with OpenPanel.
|
||||
|
||||
**OpenPanel Cloud** is the fastest way to get started. We handle the infrastructure, and your data is processed in compliance with GDPR and CCPA. You can be up and running in minutes with just a [simple script tag](/docs/get-started/install-openpanel).
|
||||
|
||||
**Self-hosted OpenPanel** gives you maximum control and compliance flexibility. It's ideal for healthcare organizations, enterprises with strict data residency requirements, or anyone who wants complete ownership of their analytics data.
|
||||
|
||||
Either way, you get [cookieless tracking](/articles/cookieless-analytics), [real-time dashboards](/docs), [funnels](/articles/how-to-create-a-funnel), user profiles, and all the features you need to understand your users without the compliance complexity.
|
||||
|
||||
[Get started with OpenPanel Cloud](https://dashboard.openpanel.dev/onboarding) or check out our [self-hosting documentation](/docs/self-hosting/self-hosting).
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="Does OpenPanel use cookies?">
|
||||
No. OpenPanel uses cookieless tracking by default. This means you don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel GDPR compliant?">
|
||||
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I need a BAA to use OpenPanel for healthcare analytics?">
|
||||
If you use OpenPanel Cloud, you would need to discuss BAA requirements with us. However, if you self-host OpenPanel on your own HIPAA-compliant infrastructure, no BAA is required because the data never leaves your environment.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I use OpenPanel without a cookie banner?">
|
||||
Yes. Since OpenPanel doesn't use cookies, you don't need a cookie consent banner for your analytics. However, you should still have a privacy policy that explains what data you collect.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Where is OpenPanel Cloud data stored?">
|
||||
OpenPanel Cloud infrastructure is based in the EU. For specific data residency requirements, self-hosting gives you complete control over where your data lives.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How does self-hosting help with compliance?">
|
||||
Self-hosting eliminates third-party data sharing, which simplifies compliance with GDPR, CCPA, HIPAA, and other regulations. Your data never leaves your infrastructure, so there's no vendor management, no international data transfers to worry about, and no BAAs required.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I migrate from Google Analytics to OpenPanel?">
|
||||
Yes. OpenPanel can replace Google Analytics for most use cases. We offer both web analytics and product analytics features. Check our comparison with other platforms like the [Google Analytics alternative](/compare/google-analytics-alternative) page.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel open source?">
|
||||
Yes. OpenPanel is fully open source and available on GitHub. You can audit the code, contribute, or fork it for your own needs.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
@@ -181,7 +181,7 @@ It might not be the best fit if:
|
||||
|
||||
Installing GroupMQ is straightforward:
|
||||
|
||||
```bash
|
||||
```npm
|
||||
npm install groupmq
|
||||
```
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Discover how to gather meaningful insights without cookies and why
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-06-17
|
||||
updated: 2025-12-02
|
||||
cover: /content/cookieless-analytics.jpg
|
||||
---
|
||||
|
||||
@@ -58,6 +59,8 @@ When you rely on your own data sources:
|
||||
|
||||
We built OpenPanel from the ground up with privacy at its heart—and with features you actually need:
|
||||
|
||||
> See how OpenPanel compares to other cookieless analytics tools: [OpenPanel vs Plausible](/compare/plausible-alternative) | [OpenPanel vs Fathom](/compare/fathom-alternative)
|
||||
|
||||
### Privacy by Default
|
||||
|
||||
* **Zero cookies.** Ever.
|
||||
@@ -88,9 +91,7 @@ We built OpenPanel from the ground up with privacy at its heart—and with featu
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.op = window.op || function(...args) {
|
||||
(window.op.q = window.op.q || []).push(args);
|
||||
};
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: How to Self-Host OpenPanel Analytics Platform
|
||||
description: Learn how to self-host OpenPanel web analytics platform. Step-by-step guide to install and configure your own analytics server for better privacy and cost savings.
|
||||
date: 2025-02-28
|
||||
updated: 2025-12-02
|
||||
cover: /content/how-to-self-host-openpanel.jpg
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
@@ -23,6 +24,8 @@ cd openpanel/self-hosting
|
||||
|
||||
Looking for a [Mixpanel alternative](/articles/alternatives-to-mixpanel)? Self-hosting your own web analytics and product analytics platform comes with several benefits. Let's break down the pros and cons of running your own analytics server.
|
||||
|
||||
For a comparison of all open source analytics platforms, see our [comprehensive guide to open source web analytics tools](/articles/open-source-web-analytics).
|
||||
|
||||
### Cost Benefits
|
||||
|
||||
Self-hosting your own web analytics solution is typically much more cost-effective than cloud-based alternatives, especially as your traffic grows. This is one of the primary reasons organizations choose to self-host their analytics.
|
||||
|
||||
@@ -4,6 +4,7 @@ description: OpenPanel is a versatile analytics platform that offers a wide arra
|
||||
tag: Introduction
|
||||
team: OpenPanel Team
|
||||
date: 2024-11-09
|
||||
updated: 2025-12-02
|
||||
---
|
||||
|
||||
Welcome to OpenPanel, the open-source analytics platform designed to be a robust alternative to Mixpanel and a great substitute for Google Analytics. In this article, we'll explore why OpenPanel is the ideal choice for businesses looking to leverage powerful analytics while maintaining control over their data.
|
||||
@@ -16,6 +17,8 @@ At OpenPanel, we are committed to the principles of open-source software. By mak
|
||||
|
||||
Our journey began with a vision to create an open-source alternative to Mixpanel, a tool we admired for its product analytics capabilities. However, as we developed OpenPanel, we realized the potential to offer more comprehensive features that Mixpanel lacked, particularly in the realm of web analytics. While Mixpanel excels in product analytics, it doesn't fully address web analytics needs. OpenPanel bridges this gap by integrating both web and product analytics, providing a holistic view of user behavior.
|
||||
|
||||
For a detailed comparison with other tools, see our guide on [open source web analytics](/articles/open-source-web-analytics).
|
||||
|
||||
## What Can You Do with OpenPanel?
|
||||
|
||||
OpenPanel is a versatile analytics platform that offers a wide array of features to meet your data analysis needs:
|
||||
|
||||
@@ -13,7 +13,7 @@ Mixpanel revolutionized product analytics by making it easy to track user behavi
|
||||
|
||||
The surge in demand for Mixpanel alternatives stems from several pain points that teams frequently encounter:
|
||||
|
||||
**Pricing Shock**: Mixpanel's pricing can escalate quickly as your user base grows. What starts as a manageable $28/month can balloon to thousands of dollars once you exceed the free tier limits. For startups and growing companies, this unpredictable cost structure makes budgeting difficult.
|
||||
**Pricing Shock**: [Mixpanel's pricing](/articles/mixpanel-pricing) can escalate quickly as your user base grows. What starts as a manageable $28/month can balloon to thousands of dollars once you exceed the free tier limits. For startups and growing companies, this unpredictable cost structure makes budgeting difficult.
|
||||
|
||||
**Data Privacy Concerns**: With increasing privacy regulations like GDPR and CCPA, many companies need analytics tools that prioritize user privacy. Mixpanel's cloud-based model means your user data lives on their servers, which can be a compliance headache for privacy-conscious organizations.
|
||||
|
||||
|
||||
241
apps/public/content/articles/mixpanel-pricing.mdx
Normal file
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: Mixpanel Pricing
|
||||
description: A complete breakdown of Mixpanel's pricing plans, what's included, hidden costs to watch for, and how it compares to alternatives like OpenPanel.
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-12-08
|
||||
cover: /content/mixpanel-pricing-cover.jpg
|
||||
---
|
||||
|
||||
Mixpanel is one of the most popular product analytics tools out there. It's powerful, well-designed, and used by thousands of companies. But before you commit, you probably want to understand exactly what you'll be paying.
|
||||
|
||||
Mixpanel's pricing has gone through several changes over the years. They've made it more transparent recently, which is great. But there are still some things that aren't immediately obvious when you're looking at their pricing page.
|
||||
|
||||
In this guide, we'll break down exactly how Mixpanel pricing works, what you get at each tier, and some things to watch out for as your usage grows.
|
||||
|
||||
## How Mixpanel Pricing Works
|
||||
|
||||
Mixpanel uses event-based pricing. This means you pay based on the number of events you track, not the number of users on your team or the number of projects you have.
|
||||
|
||||
An "event" is any user action you decide to track. A button click, a page view, a purchase, a sign-up. Each one counts as an event. If you track 10 different actions and have 10,000 monthly active users, you could easily be looking at millions of events per month.
|
||||
|
||||
Mixpanel offers three main plans: Free, Growth, and Enterprise.
|
||||
|
||||
## Mixpanel Free Plan
|
||||
|
||||
The free plan is genuinely generous for getting started. You get up to 20 million events per month, which sounds like a lot. For many small projects and early-stage startups, this is plenty.
|
||||
|
||||
Here's what's included in the free plan:
|
||||
|
||||
The core analytics features work well. You get access to Insights, Funnels, Flows, and Retention reports. You can create unlimited reports and dashboards. The data is real-time, and you can segment by any property you're tracking.
|
||||
|
||||
However, there are limitations. You don't get access to some of the more advanced features like behavioral cohorts, saved metrics, or custom properties beyond the basics. The free plan also doesn't include Group Analytics, which is pretty much essential if you're building a B2B product and want to analyze data at the company level rather than just individual users.
|
||||
|
||||
There's also no data export on the free plan. If you want to pull your data into a warehouse or analyze it elsewhere, you'll need to upgrade.
|
||||
|
||||
## Mixpanel Growth Plan
|
||||
|
||||
The Growth plan is where most paying customers land. As of early 2025, Mixpanel made the Growth plan more accessible by including the first 1 million events free each month when you add a credit card.
|
||||
|
||||
After that first million, you pay based on your event volume. The pricing scales something like this:
|
||||
|
||||
- 1.5 million events: around $140/month
|
||||
- 5 million events: around $612.5/month
|
||||
- 10 million events: around $1,176/month
|
||||
- 20 million events: around $2,289/month
|
||||
|
||||
These are approximate figures since Mixpanel's pricing page uses a slider and the exact numbers depend on your specific configuration.
|
||||
|
||||
The Growth plan adds several important features over Free. You get full access to advanced analysis tools including multi-touch attribution, formulas, saved metrics, and behavioral cohorts. Custom properties become more flexible. You also get access to experiment analysis and more detailed reporting.
|
||||
|
||||
One thing to note: these prices are for the core analytics. Add-ons cost extra.
|
||||
|
||||
<Figure
|
||||
src="/content/mixpanel-pricing.png"
|
||||
caption="Mixpanel pricing showing the different plans and their prices"
|
||||
/>
|
||||
|
||||
## Free vs Growth Plan: What's the Difference?
|
||||
|
||||
If you're trying to decide whether to stay on Free or upgrade to Growth, here's a detailed breakdown of what you get with each plan:
|
||||
|
||||
| Feature | Free Plan | Growth Plan |
|
||||
|---------|-----------|-------------|
|
||||
| **Monthly events** | Up to 1M | First 1M free, up to 20M |
|
||||
| **Saved reports** | 5 per seat | Unlimited |
|
||||
| **Session replays** | 10K/month | 20K free (up to 500K) |
|
||||
| **Spark AI queries** | 30/month | 60/month |
|
||||
| **Behavioral cohorts** | Limited | Full access |
|
||||
| **Custom properties** | Limited | Full access |
|
||||
| **Formulas & saved metrics** | ❌ | ✅ |
|
||||
| **Impact & statistical significance** | ❌ | ✅ |
|
||||
| **Multi-touch attribution** | ❌ | ✅ |
|
||||
| **Monitoring alerts** | 5 per project | Unlimited |
|
||||
| **Anomaly detection** | ❌ | ✅ |
|
||||
| **Root cause analysis** | ❌ | ✅ |
|
||||
| **Experiment reporting** | ❌ | Add-on |
|
||||
| **Feature flags** | ❌ | Add-on |
|
||||
| **Account-level analytics** | ❌ | Add-on |
|
||||
| **Data pipelines** | ❌ | Add-on |
|
||||
| **Lookup tables** | Limited | Full access |
|
||||
| **Support** | Email (standard) | Email (24/5) |
|
||||
|
||||
The key takeaway: Free is genuinely useful for basic analytics, but once you need advanced features like formulas, cohort analysis, or more than 5 saved reports per person, you'll need to upgrade. And even on Growth, several important features like account-level analytics and data pipelines are still add-ons.
|
||||
|
||||
## Mixpanel Enterprise Plan
|
||||
|
||||
Enterprise pricing isn't published. You need to contact sales, and pricing depends on your specific needs, event volume, and how good you are at negotiating.
|
||||
|
||||
Based on publicly available information from sites that track B2B pricing, Enterprise plans typically start around $20,000 per year for MTU-based pricing (monthly tracked users) or around $27,000 per year for 300 million events on event-based pricing.
|
||||
|
||||
Enterprise adds features like SSO, advanced permissions and data governance, dedicated support, custom contracts, and access to some exclusive reports like Signal and Impact.
|
||||
|
||||
If you're a larger organization with compliance requirements or need specific security features, Enterprise is likely where you'll end up.
|
||||
|
||||
## The Add-Ons That Add Up
|
||||
|
||||
Here's where Mixpanel pricing gets a bit tricky. Several features that you might consider core functionality are actually paid add-ons, even on Growth plans.
|
||||
|
||||
**Group Analytics** is the big one. If you're building a B2B product, you almost certainly need this. It lets you analyze data at the account or company level, not just individual users. Without it, you can't answer basic questions like "which companies are most engaged?" or "what's our retention by account?" Group Analytics is a separate line item on your bill.
|
||||
|
||||
**Data Pipelines** is another common add-on. This lets you export your Mixpanel data to a data warehouse like BigQuery, Snowflake, or Redshift. Based on publicly available pricing data, this can add around $19,000+ annually for larger implementations. If you need your analytics data in your warehouse for broader analysis or reporting, this cost adds up.
|
||||
|
||||
**Session Replay** lets you watch recordings of user sessions. It's useful for understanding the context behind your quantitative data, but it's another add-on with its own pricing.
|
||||
|
||||
**Warehouse Connectors** allow you to import data from your warehouse into Mixpanel. Again, separate pricing.
|
||||
|
||||
When budgeting for Mixpanel, make sure you factor in which add-ons you'll actually need. The base plan price can be misleading if you end up needing two or three add-ons to do what you want.
|
||||
|
||||
## Mixpanel Startup Program
|
||||
|
||||
If you're an early-stage startup, Mixpanel offers a pretty solid deal. Their startup program gives you access to a "Startup Plan" free for one year.
|
||||
|
||||
To qualify, your company needs to be founded less than 5 years ago, have less than $8 million in total funding, and not have previously redeemed similar offers.
|
||||
|
||||
The Startup Plan includes advanced features, Group Analytics, Data Pipelines, Warehouse Connectors, and Session Replay. You get up to 1 billion events over the year and 500,000 session replay recordings.
|
||||
|
||||
There's a catch though: you need to start sending data within 90 days of acceptance, or you get removed from the program. And after the year is up, you'll need to move to a paid plan or downgrade to Free.
|
||||
|
||||
It's a good deal if you qualify, but plan ahead for what happens when that first year ends.
|
||||
|
||||
## When Mixpanel Gets Expensive
|
||||
|
||||
Mixpanel's event-based pricing means your costs are directly tied to your growth. The more successful your product becomes, the more you pay. This makes sense from Mixpanel's perspective, but it can create some challenges for growing companies.
|
||||
|
||||
Here's a scenario. Let's say you're tracking 15 different events per user. Your product has 50,000 monthly active users, each doing an average of 20 tracked actions per session, with 3 sessions per month. That's 15 × 50,000 × 20 × 3 = 45 million events per month. You've already blown past the free tier and are looking at significant monthly costs.
|
||||
|
||||
Now imagine you launch a marketing campaign that doubles your user base. Your analytics bill just doubled too.
|
||||
|
||||
Some teams respond to this by tracking fewer events or being very selective about what they measure. That's not ideal. You want your analytics to grow with your product, not become a constraint on what you can learn about your users.
|
||||
|
||||
The other thing that catches people off guard is the gap between the free tier and paid pricing. 20 million events is free. But once you hit 20,000,001 events, you're on a paid plan. The jump can feel steep if you weren't expecting it.
|
||||
|
||||
## What Users Say About Mixpanel Pricing
|
||||
|
||||
Looking at reviews on G2, Capterra, and similar sites, pricing is one of the most common complaints about Mixpanel. Here are some themes that come up repeatedly:
|
||||
|
||||
> "The jump from free to paid can be steep." Many users start on the generous free tier, get comfortable with the tool, and then face a significant cost when they outgrow it.
|
||||
|
||||
> "Gets expensive at scale." Companies with large user bases or those tracking many events find costs escalating quickly.
|
||||
|
||||
> "Add-ons feel like they should be included." Group Analytics in particular gets called out. For B2B products, it's essentially a required feature, but it's priced separately.
|
||||
|
||||
> "Pricing forced us to track less." Some users report deliberately limiting their tracking to stay within budget, which defeats the purpose of having comprehensive analytics.
|
||||
|
||||
To be fair, there are also plenty of users who think Mixpanel provides good value, especially compared to building custom analytics infrastructure. The complaints tend to come from teams that have scaled beyond the free tier and are comparing costs to alternatives.
|
||||
|
||||
## Mixpanel vs OpenPanel: A Direct Comparison
|
||||
|
||||
Since you're reading this on the OpenPanel blog, let's be upfront about how we compare. We built [OpenPanel](/articles/introduction-to-openpanel) specifically as a more affordable alternative to Mixpanel, so we think the comparison is worth making.
|
||||
|
||||
Here's how the pricing stacks up at different event volumes:
|
||||
|
||||
| Monthly Events | Mixpanel Growth | OpenPanel Cloud |
|
||||
|---------------|-----------------|-----------------|
|
||||
| 100K | ~$28* | $20 |
|
||||
| 500K | ~$70* | $50 |
|
||||
| 1M | Free (with card) | $90 |
|
||||
| 1.5M | ~$140 | $180 |
|
||||
| 5M | ~$612 | $250 |
|
||||
| 10M | ~$1,176 | $350 |
|
||||
| 20M | ~$2,289 | $530 |
|
||||
| 50M | Contact sales | $900 |
|
||||
|
||||
*Mixpanel's free tier covers up to 20M events, but with limited features
|
||||
|
||||
At 20 million events, OpenPanel is about 77% cheaper than Mixpanel's Growth plan. But the pricing difference is only part of the story.
|
||||
|
||||
**What's included matters.** With [OpenPanel pricing](/pricing), everything is included. Unlimited websites, unlimited users, unlimited dashboards. There are no tiers within tiers, no add-ons, no "contact sales for this feature." You pick your event volume and that's your price.
|
||||
|
||||
**Self-hosting is an option.** If you want to go even further on cost savings, you can [self-host OpenPanel](/articles/how-to-self-host-openpanel) for free. Your only cost is the infrastructure itself, which can be surprisingly affordable. A decent VPS can handle millions of events and costs maybe $20-50/month.
|
||||
|
||||
**Privacy by default.** OpenPanel uses [cookieless tracking](/articles/cookieless-analytics) out of the box. No cookie consent banners needed. This isn't just about compliance, it means you get more accurate data because you're not losing users who decline cookies.
|
||||
|
||||
Obviously, we're biased here. Mixpanel has been around longer, has more integrations, and has a larger team building features. If you need very specific capabilities that only Mixpanel offers, it might be worth the premium. But for most teams doing product analytics, OpenPanel covers the core use cases at a fraction of the cost.
|
||||
|
||||
You can see a more detailed comparison on our [Mixpanel alternative](/compare/mixpanel-alternative) page.
|
||||
|
||||
## Tips for Managing Mixpanel Costs
|
||||
|
||||
If you decide Mixpanel is the right tool for you, here are some ways to keep costs under control:
|
||||
|
||||
**Be intentional about what you track.** Don't track everything just because you can. Define your key metrics and the events that feed into them. You can always add more tracking later if you need it.
|
||||
|
||||
**Use the startup program if you qualify.** That free year gives you runway to grow before you need to worry about analytics costs.
|
||||
|
||||
**Consider annual billing.** Mixpanel typically offers 10-15% discounts for annual commitments. If you're confident you'll stick with the tool, this is easy savings.
|
||||
|
||||
**Audit your tracking regularly.** Over time, teams tend to accumulate tracking that's no longer used. Old features get deprecated, experiments end, but the events keep flowing. A quarterly audit can help you trim unnecessary events.
|
||||
|
||||
**Negotiate at renewal.** B2B SaaS pricing is often negotiable, especially at higher volumes. Don't just accept the renewal quote, ask what flexibility exists.
|
||||
|
||||
## Making the Decision
|
||||
|
||||
Mixpanel is a good product. The analytics are powerful, the UI is well-designed, and there's a reason it's one of the most popular tools in the category.
|
||||
|
||||
But pricing matters. If you're a growing startup watching your runway, or a bootstrapped company keeping costs lean, or an enterprise trying to justify spend to finance, you need to factor in the true cost of your analytics stack.
|
||||
|
||||
The questions to ask yourself:
|
||||
|
||||
1. How many events will you realistically track as you grow?
|
||||
2. Do you need Group Analytics for B2B analysis?
|
||||
3. Do you need data export to a warehouse?
|
||||
4. What happens to your budget when your user base doubles?
|
||||
|
||||
If the answers to those questions make you nervous about Mixpanel's pricing trajectory, it might be worth looking at alternatives before you're locked in.
|
||||
|
||||
[OpenPanel](/) offers a 30-day free trial with no credit card required. You can try it alongside Mixpanel and see which fits better for your needs and budget. And if you want maximum control over costs and data, [self-hosting](/docs/self-hosting/self-hosting) is always an option.
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="How much does Mixpanel cost per month?">
|
||||
Mixpanel's cost depends on your event volume. The free plan covers up to 20 million events with limited features. The Growth plan starts with 1 million free events (with credit card), then scales from around $28/month for additional events up to $2,289/month for 20 million events. Enterprise pricing requires contacting sales but typically starts around $20,000/year.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is Mixpanel free to use?">
|
||||
Yes, Mixpanel has a free tier that includes up to 20 million events per month. However, it comes with feature limitations. You don't get Group Analytics, data export, advanced cohorts, or some other features. For basic product analytics on smaller projects, the free tier can work well.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What counts as an event in Mixpanel?">
|
||||
An event is any user action you choose to track. This includes button clicks, page views, sign-ups, purchases, form submissions, feature usage, or any custom action you define. Each occurrence counts as one event toward your monthly total.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is Mixpanel worth the price?">
|
||||
It depends on your needs and budget. Mixpanel is a powerful tool with excellent analytics capabilities. For well-funded companies that need its specific features, it can be worth it. For cost-conscious teams, the pricing can escalate quickly as you grow. Alternatives like OpenPanel offer similar core functionality at significantly lower prices.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Does Mixpanel charge for Group Analytics?">
|
||||
Yes. Group Analytics is a paid add-on, even on Growth plans. This feature is essential for B2B products that need to analyze data at the company or account level rather than just individual users. The additional cost isn't prominently displayed on the main pricing page.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How does Mixpanel pricing compare to OpenPanel?">
|
||||
OpenPanel is significantly cheaper at most event volumes. At 20 million events, OpenPanel costs $530/month compared to Mixpanel's approximately $2,289/month. OpenPanel also includes all features in every plan with no add-ons, and offers a free self-hosting option.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can startups get Mixpanel for free?">
|
||||
Yes. Mixpanel's Startup Program offers eligible startups their first year free on the Startup Plan. To qualify, your company must be founded less than 5 years ago and have less than $8 million in total funding. The plan includes advanced features and up to 1 billion events over the year.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="What happens if I go over my Mixpanel event limit?">
|
||||
On the Growth plan, you'll be charged for additional events at your plan's overage rate. Mixpanel states they don't apply punitive overcharges, you just pay the regular per-event rate. They also have a "forgiveness policy" for events tracked by mistake. On the free plan, exceeding limits may require upgrading to a paid plan.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
@@ -2,6 +2,7 @@
|
||||
title: 9 best open source web analytics tools
|
||||
description: In an era where data drives decisions, what are your best options for web analytics?
|
||||
date: 2024-11-10
|
||||
updated: 2025-12-02
|
||||
cover: /content/cover-best-web-analytics.jpg
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
@@ -102,6 +103,8 @@ Yes. OpenPanel Cloud starts with a 30-day free trial, then switches to usage-bas
|
||||
caption="PostHog"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs PostHog](/compare/posthog-alternative)
|
||||
|
||||
### Summary
|
||||
PostHog is an open-source platform for product analytics. You can track events, watch session replays, roll out feature flags, run A/B tests, track errors, send surveys, and more—all in one place. You can self-host it or use their cloud service. All users get a generous free tier every month.
|
||||
|
||||
@@ -162,6 +165,8 @@ Yes. PostHog Cloud offers a free tier (1 M events, 5 k recordings, 1 M flag API
|
||||
caption="Plausible"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Plausible](/compare/plausible-alternative)
|
||||
|
||||
### Summary
|
||||
Plausible is an open-source, privacy-friendly web analytics tool. It tracks pageviews and custom events without cookies or personal data. You get real-time reports, goals, custom events, email or Slack reports, and GDPR/CCPA compliance. You can self-host for free or use their cloud service.
|
||||
|
||||
@@ -225,6 +230,8 @@ Yes. Plausible Cloud starts at \$9/month for 10 k page-views with simple, traffi
|
||||
caption="Matomo"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Matomo](/compare/matomo-alternative)
|
||||
|
||||
### Summary
|
||||
Matomo (formerly Piwik) is an open-source web analytics platform. You can track web and mobile visits, build charts, create dashboards, set goals, run A/B tests, record sessions, view heatmaps, and more. You own all your data and can choose to self-host for free or use Matomo Cloud for hosting and support.
|
||||
|
||||
@@ -287,6 +294,8 @@ Yes. Matomo Cloud charges by "hits per month" and unlocks all paid add-ons out-o
|
||||
caption="Fathom"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Fathom](/compare/fathom-alternative)
|
||||
|
||||
### Summary
|
||||
Fathom is a simple, privacy-focused web analytics tool. It tracks pageviews and events without cookies or personal data. You get real-time reports, unlimited data retention, unlimited sites, and simple dashboards. You can use Fathom's hosted service with a free trial or self-host with their Docker image (self-hosting requires a license).
|
||||
|
||||
@@ -350,6 +359,8 @@ Fathom is a hosted, proprietary analytics service with simple, tiered pricing. O
|
||||
caption="Umami"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Umami](/compare/umami-alternative)
|
||||
|
||||
### Summary
|
||||
Umami is an open-source, privacy-friendly web analytics tool. It tracks pageviews and basic events without cookies or personal data. The lightweight script (~2 KB) loads fast and is GDPR/CCPA compliant by default. You can self-host it for free or use Umami Cloud with usage-based pricing.
|
||||
|
||||
@@ -416,6 +427,8 @@ Yes. Umami Cloud lets you track up to 100 k events each month for free, then bil
|
||||
caption="Ackee"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Ackee](/compare/ackee-alternative)
|
||||
|
||||
### Summary
|
||||
Ackee is an open-source, self-hosted web analytics tool that focuses on privacy and simplicity. It runs on your own server with Node.js and MongoDB, tracks pageviews and custom events without cookies or personal data, and presents stats in a minimal interface. It uses a GraphQL API and keeps all tracked data anonymized by default.
|
||||
|
||||
@@ -466,6 +479,8 @@ Ackee is a self-hosted, minimal web analytics tool that tracks pageviews and bas
|
||||
caption="Pirsch"
|
||||
/>
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Pirsch](/compare/pirsch-analytics-alternative)
|
||||
|
||||
### Summary
|
||||
Pirsch is a drop-in, server-side, no-cookie, privacy-focused web analytics solution built in Go. It generates anonymized visitor fingerprints, works even with ad blockers, and is GDPR, CCPA, and PECR compliant. You can use the hosted SaaS offering or self-host under an enterprise license.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Self-host your web analytics on your own infrastructure.
|
||||
tag: Hosting
|
||||
team: OpenPanel Team
|
||||
date: 2024-11-14
|
||||
updated: 2025-12-02
|
||||
cover: /content/self-hosted-analytics.jpg
|
||||
---
|
||||
|
||||
@@ -70,10 +71,13 @@ We're of course biased, but we think OpenPanel is the best self-hosted analytics
|
||||
|
||||
So depending on your needs you might consider what you choose.
|
||||
|
||||
- **Plausible** - Simple and privacy focused
|
||||
- **Plausible** - Simple and privacy focused ([compare with OpenPanel](/compare/plausible-alternative))
|
||||
- **Simple Analytics** - Simple and privacy focused
|
||||
- **Fathom** - Simple and privacy focused
|
||||
- **Fathom** - Simple and privacy focused ([compare with OpenPanel](/compare/fathom-alternative))
|
||||
- **OpenPanel** - Best of both worlds, easy to use and privacy focused
|
||||
- **Umami** - Lightweight and privacy focused ([compare with OpenPanel](/compare/umami-alternative))
|
||||
|
||||
For a detailed comparison of all options, see our guide on the [best open source web analytics tools](/articles/open-source-web-analytics).
|
||||
|
||||
Each of these platforms has guides how to set up on your own server, here is ours: [How to install OpenPanel on your own server](/docs/self-hosting/self-hosting)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: Mixpanel vs OpenPanel
|
||||
description: A comparison between Mixpanel and OpenPanel
|
||||
date: 2024-11-13
|
||||
updated: 2025-12-02
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
cover: /content/cover-mixpanel.jpg
|
||||
@@ -10,6 +11,10 @@ import { Figure } from "@/components/figure";
|
||||
|
||||
OpenPanel is based on the same principles as Mixpanel, but with a few key differences. We'll go through some of the features and see how they compare.
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Mixpanel](/compare/mixpanel-alternative)
|
||||
>
|
||||
> Looking for pricing details? Check out our [complete Mixpanel pricing breakdown](/articles/mixpanel-pricing)
|
||||
|
||||
## Web analytics
|
||||
|
||||
Mixpanel is a great product analytics tool but in our minds its lacking in this area. Web analytics should always be easy to get going and we think Mixpanel has to much focus on product analytics.
|
||||
@@ -100,4 +105,6 @@ You get new events in realtime in both Mixpanel and OpenPanel, you can search an
|
||||
|
||||
Mixpanel is a great product analytics tool but in our minds its lacking in this area. Web analytics should always be easy to get going and we think Mixpanel has to much focus on product analytics.
|
||||
|
||||
**OpenPanel is a great alternative to Mixpanel** if you want to get started with analytics quickly and easily.
|
||||
**OpenPanel is a great alternative to Mixpanel** if you want to get started with analytics quickly and easily.
|
||||
|
||||
Looking for more options? Check out our guide on [9 best open source web analytics tools](/articles/open-source-web-analytics) for a comprehensive comparison.
|
||||
529
apps/public/content/compare/ackee-alternative.json
Normal file
@@ -0,0 +1,529 @@
|
||||
{
|
||||
"slug": "ackee-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Ackee Alternative: Why Teams Choose OpenPanel",
|
||||
"description": "Compare OpenPanel vs Ackee Analytics. Discover why teams choose OpenPanel as their Ackee alternative for product analytics, user identification, and scalable analytics while maintaining privacy.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Ackee Alternative",
|
||||
"subheading": "Love Ackee's privacy-first approach and self-hosting simplicity? OpenPanel adds product analytics capabilities - funnels, cohorts, retention, and user identification - with a modern architecture built for scale.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Privacy-first",
|
||||
"Product Analytics",
|
||||
"Self-hostable"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Ackee",
|
||||
"logo": "/logos/ackee.svg",
|
||||
"url": "https://ackee.electerious.com",
|
||||
"short_description": "Minimalist, self-hosted, privacy-focused web analytics with anonymous tracking and a clean interface.",
|
||||
"founded": 2016,
|
||||
"headquarters": "Germany"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Ackee: Which is right for you?",
|
||||
"intro": "Both are privacy-focused open source analytics tools. Ackee focuses on simple web metrics with anonymous tracking. OpenPanel adds product analytics capabilities with user identification.",
|
||||
"one_liner": "Ackee is ultra-minimal web analytics; OpenPanel adds product analytics features like funnels, retention, and user identification.",
|
||||
"best_for_openpanel": [
|
||||
"Teams needing product analytics (funnels, retention, cohorts)",
|
||||
"Apps requiring user identification and tracking",
|
||||
"Projects that need to scale with ClickHouse architecture",
|
||||
"Mobile app analytics with native iOS, Android, React Native SDKs"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Personal blogs and small sites wanting simple stats",
|
||||
"Privacy maximalists who want zero user identification",
|
||||
"Developers comfortable with GraphQL API customization",
|
||||
"Sites that can run on MongoDB for analytics"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Ackee compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Analytics Depth",
|
||||
"openpanel": "Web + Product Analytics",
|
||||
"competitor": "Web Analytics Only",
|
||||
"notes": "OpenPanel combines web analytics with product analytics - funnels, retention, cohorts, and user profiles. Ackee provides essential web metrics like page views, referrers, and durations."
|
||||
},
|
||||
{
|
||||
"label": "User Identification",
|
||||
"openpanel": "Yes - Track individual users",
|
||||
"competitor": "No - Anonymous aggregate only",
|
||||
"notes": "OpenPanel lets you identify and track individual users across sessions. Ackee intentionally anonymizes all data and cannot track returning users for individual pages."
|
||||
},
|
||||
{
|
||||
"label": "Database Architecture",
|
||||
"openpanel": "ClickHouse (optimized for analytics)",
|
||||
"competitor": "MongoDB (general purpose)",
|
||||
"notes": "OpenPanel uses ClickHouse, purpose-built for analytical queries. Ackee uses MongoDB, which works well for small sites but may struggle with high-volume analytics workloads."
|
||||
},
|
||||
{
|
||||
"label": "Deployment Options",
|
||||
"openpanel": "Cloud + Self-hosted",
|
||||
"competitor": "Self-hosted only",
|
||||
"notes": "OpenPanel offers both managed cloud and self-hosted options. Ackee is self-hosted only - you must run your own server and MongoDB instance."
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes (MIT License)",
|
||||
"competitor": "Yes (MIT License)",
|
||||
"notes": "Both platforms are fully open source under the MIT license, allowing complete code inspection and modification."
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "Both are open source and privacy-focused, but with different capabilities and depth.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Web Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Page Views",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Referrers",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Visit Duration",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee tracks duration by updating records constantly"
|
||||
},
|
||||
{
|
||||
"name": "Device & Browser",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee requires 'detailed' mode for full device info"
|
||||
},
|
||||
{
|
||||
"name": "Screen Sizes",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee requires 'detailed' mode enabled"
|
||||
},
|
||||
{
|
||||
"name": "Real-Time Dashboard",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee shows active visitors in real-time"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Custom Event Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee supports custom events with key-value data"
|
||||
},
|
||||
{
|
||||
"name": "Funnel Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee has no funnel visualization or analysis"
|
||||
},
|
||||
{
|
||||
"name": "Retention Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee cannot track returning users by design"
|
||||
},
|
||||
{
|
||||
"name": "User Profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee intentionally anonymizes all user data"
|
||||
},
|
||||
{
|
||||
"name": "Cohort Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee's anonymous model doesn't support cohorts"
|
||||
},
|
||||
{
|
||||
"name": "User Path Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee cannot reconstruct user browsing history"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced Features",
|
||||
"features": [
|
||||
{
|
||||
"name": "A/B Testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee is analytics-only, no experimentation"
|
||||
},
|
||||
{
|
||||
"name": "GraphQL API",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Ackee's entire UI is powered by its GraphQL API"
|
||||
},
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel uses REST; Ackee uses GraphQL only"
|
||||
},
|
||||
{
|
||||
"name": "Email Reports",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee has no built-in reporting - requires custom tools"
|
||||
},
|
||||
{
|
||||
"name": "Multiple Domains",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support unlimited domains from one instance"
|
||||
},
|
||||
{
|
||||
"name": "Ignore Own Visits",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee can ignore logged-in dashboard visits"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & Compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Cookie-Free by Default",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both are cookieless by default"
|
||||
},
|
||||
{
|
||||
"name": "No Consent Banner Required",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both claim no consent needed for basic analytics"
|
||||
},
|
||||
{
|
||||
"name": "GDPR Compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee uses daily-rotating salt hashes for anonymization"
|
||||
},
|
||||
{
|
||||
"name": "Data Anonymization",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee uses multi-step anonymization with daily salt rotation"
|
||||
},
|
||||
{
|
||||
"name": "Self-Hosted Option",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Ackee is self-hosted ONLY; OpenPanel offers both"
|
||||
},
|
||||
{
|
||||
"name": "EU Data Residency",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both can be hosted anywhere; Ackee requires self-hosting"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & SDKs",
|
||||
"features": [
|
||||
{
|
||||
"name": "JavaScript SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "ackee-tracker available via npm or self-served"
|
||||
},
|
||||
{
|
||||
"name": "React Integration",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "use-ackee React hook available"
|
||||
},
|
||||
{
|
||||
"name": "Vue/Nuxt Integration",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "nuxt-ackee module available"
|
||||
},
|
||||
{
|
||||
"name": "Gatsby Plugin",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "gatsby-plugin-ackee-tracker available"
|
||||
},
|
||||
{
|
||||
"name": "Native iOS SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee is web-focused only"
|
||||
},
|
||||
{
|
||||
"name": "Native Android SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Ackee has no official mobile SDKs"
|
||||
},
|
||||
{
|
||||
"name": "WordPress Plugin",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Soapberry plugin available for WordPress"
|
||||
},
|
||||
{
|
||||
"name": "Flutter/Dart SDK",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "ackee_dart community package available"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK Size",
|
||||
"openpanel": "2.3 KB (gzipped)",
|
||||
"competitor": "~6 KB (gzipped), served from your Ackee instance",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Platforms",
|
||||
"openpanel": [
|
||||
"JavaScript/TypeScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"PHP",
|
||||
"Go",
|
||||
"Rust"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript (browser)",
|
||||
"React (use-ackee)",
|
||||
"Nuxt.js",
|
||||
"Vue",
|
||||
"Gatsby",
|
||||
"Svelte",
|
||||
"Angular",
|
||||
"WordPress",
|
||||
"Dart/Flutter"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes - MIT License",
|
||||
"competitor": "Yes - MIT License",
|
||||
"notes": "Both are MIT licensed and fully open source"
|
||||
},
|
||||
{
|
||||
"label": "Self Hosting",
|
||||
"openpanel": "Docker (simple single-container setup)",
|
||||
"competitor": "Docker with MongoDB required, or deploy to Vercel/Netlify/Heroku with MongoDB Atlas",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse (columnar, analytics-optimized)",
|
||||
"competitor": "MongoDB (document database)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data Retention",
|
||||
"openpanel": "Unlimited (self-hosted), configurable (cloud)",
|
||||
"competitor": "Unlimited (depends on your MongoDB storage)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Language",
|
||||
"openpanel": "TypeScript/Node.js",
|
||||
"competitor": "Node.js",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "API",
|
||||
"openpanel": "REST API",
|
||||
"competitor": "GraphQL API",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Both offer free self-hosting. OpenPanel also offers managed cloud options.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Simple pricing with 10,000 free events per month. Self-host for free with unlimited events. All features included at every tier."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "100% Free (self-hosted only)",
|
||||
"description": "Completely free - no paid tiers exist. You pay only for your own infrastructure (MongoDB and server hosting). MongoDB Atlas free tier (512MB) works for small sites.",
|
||||
"free_tier": "Self-hosted: Free forever, no paid option",
|
||||
"pricing_url": null
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Ackee to OpenPanel",
|
||||
"intro": "Switching from Ackee to OpenPanel is straightforward. Both are lightweight with similar integration approaches.",
|
||||
"difficulty": "easy",
|
||||
"estimated_time": "30 minutes to 2 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK to your application. Replace the ackee-tracker script with OpenPanel's lightweight tracking code."
|
||||
},
|
||||
{
|
||||
"title": "Map Events to OpenPanel",
|
||||
"description": "Ackee events translate to OpenPanel events. Replace instance.action() calls with op.track() - the structure is similar."
|
||||
},
|
||||
{
|
||||
"title": "Add User Identification (New)",
|
||||
"description": "Unlike Ackee, OpenPanel can identify users. Add op.identify() calls to unlock retention, cohorts, and user profiles - a major upgrade from Ackee's anonymous-only model."
|
||||
},
|
||||
{
|
||||
"title": "Configure Dashboards",
|
||||
"description": "Set up your OpenPanel dashboards. You'll now have access to funnels, retention charts, and cohort analysis that weren't possible in Ackee."
|
||||
},
|
||||
{
|
||||
"title": "Remove Ackee",
|
||||
"description": "Once verified, remove the Ackee tracker script and optionally decommission your Ackee server and MongoDB instance."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use simple event tracking APIs. Ackee's instance.action() becomes OpenPanel's op.track()."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Ackee intentionally anonymizes data, making historical import challenging. Most teams start fresh with OpenPanel."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Ackee",
|
||||
"intro": "Choose OpenPanel when you need more than simple web stats - funnels, retention, user identification, and scalability.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Scaling Beyond MongoDB",
|
||||
"description": "Ackee uses MongoDB which works for small sites but isn't optimized for analytical queries at scale. OpenPanel's ClickHouse backend is purpose-built for analytics workloads.",
|
||||
"icon": "trending-up"
|
||||
},
|
||||
{
|
||||
"title": "Teams Needing Product Analytics",
|
||||
"description": "Ackee provides basic web metrics. If you need funnels, retention analysis, cohort breakdowns, or user journey mapping, OpenPanel adds these capabilities while maintaining privacy.",
|
||||
"icon": "bar-chart"
|
||||
},
|
||||
{
|
||||
"title": "Wanting a Cloud Option",
|
||||
"description": "Ackee is self-hosted only with no managed cloud option. If you want analytics without managing servers, MongoDB, and backups, OpenPanel offers a free cloud tier.",
|
||||
"icon": "cloud"
|
||||
},
|
||||
{
|
||||
"title": "Apps Requiring User Identification",
|
||||
"description": "Ackee intentionally cannot identify users or track returning visitors. If you need to understand individual user behavior or measure retention, OpenPanel provides these features.",
|
||||
"icon": "user-check"
|
||||
},
|
||||
{
|
||||
"title": "Mobile App Analytics",
|
||||
"description": "Ackee is designed for websites with no official mobile SDKs. OpenPanel offers native SDKs for iOS, Android, and React Native with full product analytics capabilities.",
|
||||
"icon": "smartphone"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Ackee to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is OpenPanel as privacy-friendly as Ackee?",
|
||||
"answer": "Yes! Both are cookie-free by default and can be GDPR compliant without consent banners. The key difference is that OpenPanel allows optional user identification for product analytics, while Ackee is strictly anonymous. You control whether to identify users in OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Why switch from Ackee to OpenPanel?",
|
||||
"answer": "Teams typically switch when they need: product analytics (funnels, retention, cohorts), user identification, a managed cloud option, mobile SDKs, or a database optimized for analytics. Ackee excels at simple, anonymous web stats but lacks these capabilities."
|
||||
},
|
||||
{
|
||||
"question": "Is Ackee really free?",
|
||||
"answer": "Yes, Ackee itself is 100% free and open source. However, you must pay for your own hosting - a server to run Ackee and MongoDB. Using free tiers (Vercel + MongoDB Atlas 512MB), you can run Ackee at zero cost for small sites."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have a GraphQL API like Ackee?",
|
||||
"answer": "No, OpenPanel uses a REST API while Ackee uses GraphQL. Both approaches allow building custom tools and integrations. Ackee's entire UI is powered by its GraphQL API, making it very extensible."
|
||||
},
|
||||
{
|
||||
"question": "Can Ackee track mobile apps?",
|
||||
"answer": "Not officially. Ackee is designed for web analytics. There's a community Dart/Flutter package, but no official iOS or Android SDKs. OpenPanel offers native mobile SDKs with full product analytics."
|
||||
},
|
||||
{
|
||||
"question": "What will I lose switching from Ackee?",
|
||||
"answer": "Ackee's GraphQL API (OpenPanel uses REST), the ability to host on platforms like Vercel/Netlify as serverless functions, and the community plugins like Svelte and Angular integrations. You'll gain product analytics, user identification, and a managed cloud option."
|
||||
},
|
||||
{
|
||||
"question": "How does the database compare?",
|
||||
"answer": "Ackee uses MongoDB (general-purpose document database) while OpenPanel uses ClickHouse (columnar database optimized for analytics). ClickHouse is significantly faster for analytical queries, especially at scale."
|
||||
},
|
||||
{
|
||||
"question": "Can Ackee track unique visitors?",
|
||||
"answer": "Yes, but with limitations. Ackee uses a daily-rotating salt hash (IP + user-agent + domain + salt) to identify visitors within a single day. It cannot track returning visitors across days or track unique views per page - this is by design for privacy."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
},
|
||||
{
|
||||
"title": "Self-hosted web analytics",
|
||||
"url": "/articles/self-hosted-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Umami",
|
||||
"url": "/compare/umami-alternative"
|
||||
},
|
||||
{
|
||||
"name": "GoatCounter",
|
||||
"url": "/compare/goatcounter-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
492
apps/public/content/compare/amplitude-alternative.json
Normal file
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"slug": "amplitude-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Amplitude alternative",
|
||||
"description": "Compare OpenPanel with Amplitude: open-source, privacy-first analytics with a lighter SDK. Get web and product analytics without the complexity or enterprise pricing.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Amplitude alternative",
|
||||
"subheading": "OpenPanel is an open-source, privacy-first alternative to Amplitude. Get powerful product analytics with web analytics built in, cookie-free tracking, and the freedom to self-host or use our cloud.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Cookie-free",
|
||||
"EU-only hosting",
|
||||
"Self-hostable"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Amplitude",
|
||||
"logo": "/logos/amplitude.svg",
|
||||
"url": "https://amplitude.com",
|
||||
"short_description": "Enterprise digital analytics platform for product teams, offering behavioral analytics, experimentation, and customer data management.",
|
||||
"founded": 2012,
|
||||
"headquarters": "San Francisco, CA"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Amplitude: Which is right for you?",
|
||||
"intro": "Both platforms help product teams understand user behavior. The key differences are pricing model, complexity, privacy approach, and deployment options.",
|
||||
"one_liner": "OpenPanel is simpler, open-source, and privacy-focused; Amplitude is an enterprise-grade platform with a steep learning curve.",
|
||||
"best_for_openpanel": [
|
||||
"Teams that want powerful analytics without enterprise complexity",
|
||||
"Privacy-conscious organizations that need cookie-free tracking",
|
||||
"Developers who want to self-host or need a lightweight SDK",
|
||||
"Startups and SMBs looking for transparent, predictable pricing"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Large enterprises with dedicated analytics teams",
|
||||
"Organizations that need advanced experimentation and feature flags",
|
||||
"Teams requiring sophisticated behavioral cohorts and predictive analytics",
|
||||
"Companies wanting an all-in-one platform with session replay and guides"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Amplitude compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Price",
|
||||
"openpanel": "From $2.50/month (or free self-hosted)",
|
||||
"competitor": "Free tier (50K MTUs), then $49+/month"
|
||||
},
|
||||
{
|
||||
"label": "Cookies required",
|
||||
"openpanel": "No (cookie-free by default)",
|
||||
"competitor": "Yes (first-party cookies)"
|
||||
},
|
||||
{
|
||||
"label": "Consent banner needed",
|
||||
"openpanel": "No",
|
||||
"competitor": "Yes (for GDPR/ePrivacy)"
|
||||
},
|
||||
{
|
||||
"label": "Data location",
|
||||
"openpanel": "EU-only (or your own servers)",
|
||||
"competitor": "US or EU (Frankfurt)"
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (AGPL-3.0)",
|
||||
"competitor": "No"
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "OpenPanel combines web analytics with product analytics in a simpler interface. Amplitude focuses on deep behavioral analytics for enterprise product teams.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Web analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Page views & visitors",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Traffic sources",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Geographic data",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Device & browser stats",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "UTM tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude has advanced marketing attribution"
|
||||
},
|
||||
{
|
||||
"name": "Real-time dashboard",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Custom event tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Funnels",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude has advanced conversion funnels"
|
||||
},
|
||||
{
|
||||
"name": "Retention analysis",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude has multiple retention views"
|
||||
},
|
||||
{
|
||||
"name": "User profiles",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cohorts",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude has behavioral and predictive cohorts"
|
||||
},
|
||||
{
|
||||
"name": "Revenue tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced features",
|
||||
"features": [
|
||||
{
|
||||
"name": "A/B testing",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude Experiment is full-featured"
|
||||
},
|
||||
{
|
||||
"name": "Feature flags",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude includes unlimited feature flags"
|
||||
},
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Included in Amplitude platform"
|
||||
},
|
||||
{
|
||||
"name": "Custom dashboards",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Pathfinder/Journey maps",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude pathfinder is highly advanced"
|
||||
},
|
||||
{
|
||||
"name": "Predictive analytics",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude has ML-powered predictions (Growth+)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Cookie-free tracking",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Amplitude uses first-party cookies"
|
||||
},
|
||||
{
|
||||
"name": "No consent banner needed",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Amplitude requires consent under GDPR"
|
||||
},
|
||||
{
|
||||
"name": "EU data residency",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude offers EU data center in Frankfurt"
|
||||
},
|
||||
{
|
||||
"name": "IP anonymization",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude can disable IP storage"
|
||||
},
|
||||
{
|
||||
"name": "Self-hosting option",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Amplitude is cloud-only"
|
||||
},
|
||||
{
|
||||
"name": "DPA available",
|
||||
"openpanel": "on request",
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations",
|
||||
"features": [
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Data export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude supports S3, Snowflake, BigQuery"
|
||||
},
|
||||
{
|
||||
"name": "CDP integrations",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Amplitude has its own CDP product"
|
||||
},
|
||||
{
|
||||
"name": "Webhook notifications",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the SDKs and technical implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK size (JS)",
|
||||
"openpanel": "~2.3 KB gzipped",
|
||||
"competitor": "~36 KB gzipped (analytics only), ~96 KB unified",
|
||||
"notes": "Amplitude's unified SDK includes analytics, experiment, session replay, and guides"
|
||||
},
|
||||
{
|
||||
"label": "Supported platforms",
|
||||
"openpanel": [
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"Swift",
|
||||
"Kotlin",
|
||||
"React Native",
|
||||
"Astro",
|
||||
"Remix",
|
||||
"Express"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Flutter",
|
||||
"Unity",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"Go",
|
||||
"JRE/Java"
|
||||
],
|
||||
"notes": "Both have comprehensive SDK coverage"
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (AGPL-3.0)",
|
||||
"competitor": "No (SDKs are open, platform is not)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Self-hosted deployment",
|
||||
"openpanel": "Docker, simple setup script",
|
||||
"competitor": "Not available",
|
||||
"notes": "Amplitude is cloud-only"
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse + PostgreSQL",
|
||||
"competitor": "Proprietary (AWS-hosted)",
|
||||
"notes": "OpenPanel gives you direct SQL access to your data"
|
||||
},
|
||||
{
|
||||
"label": "Data retention",
|
||||
"openpanel": "Unlimited (self-hosted) or plan-based",
|
||||
"competitor": "Unlimited storage, 3-year query limit on charts",
|
||||
"notes": "Amplitude stores data but limits chart date ranges"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Amplitude has a free tier but pricing scales quickly. OpenPanel offers simpler, more predictable costs.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Simple pricing starting at $2.50/month for 5,000 events. 100,000 events costs $20/month. Self-host for free with unlimited events. No limits on users, dashboards, or data retention."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "MTU-based with event limits",
|
||||
"description": "Starter (Free): Up to 50K MTUs and 10M events. Plus: $49-1,000+/month based on MTUs (1K-300K). Growth: Custom pricing, typically $22K-254K/year. Enterprise: Custom pricing for large organizations. Overages charged at 1.2x rate.",
|
||||
"free_tier": "Yes (50K MTUs, limited features)",
|
||||
"pricing_url": "https://amplitude.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Amplitude to OpenPanel",
|
||||
"intro": "Switching from Amplitude to OpenPanel is straightforward. The event tracking APIs are similar, and you can run both tools in parallel during transition.",
|
||||
"difficulty": "easy",
|
||||
"estimated_time": "2-4 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install the OpenPanel SDK",
|
||||
"description": "Add the lightweight OpenPanel SDK to your app. At 2.3 KB, it's significantly smaller than Amplitude's SDK."
|
||||
},
|
||||
{
|
||||
"title": "Map your events",
|
||||
"description": "Review your Amplitude tracking plan and map events to OpenPanel. The track() API is similar: amplitude.track('event') becomes op.track('event')."
|
||||
},
|
||||
{
|
||||
"title": "Configure user identification",
|
||||
"description": "Set up user identification in OpenPanel. The identify() method works similarly to Amplitude's."
|
||||
},
|
||||
{
|
||||
"title": "Set up dashboards",
|
||||
"description": "Create your key reports in OpenPanel. While you'll lose Amplitude's advanced features like predictive cohorts, core analytics translate directly."
|
||||
},
|
||||
{
|
||||
"title": "Run in parallel and validate",
|
||||
"description": "Keep both tools running for a week to compare data and ensure accuracy before removing Amplitude."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use event-based tracking with similar APIs. amplitude.track('Purchase', {amount: 99}) becomes op.track('Purchase', {amount: 99})."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Amplitude data export is available for paid plans but requires manual transformation. Most teams start fresh with OpenPanel."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Amplitude",
|
||||
"intro": "OpenPanel shines for teams that want powerful analytics without enterprise complexity, pricing, or privacy concerns.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Startups and growing teams",
|
||||
"description": "Get the product analytics you need without Amplitude's complexity or enterprise pricing. Start free and scale predictably.",
|
||||
"icon": "rocket"
|
||||
},
|
||||
{
|
||||
"title": "Privacy-first products",
|
||||
"description": "Cookie-free tracking means no consent banners and happier users. Perfect for GDPR compliance without compromise.",
|
||||
"icon": "shield"
|
||||
},
|
||||
{
|
||||
"title": "Self-hosted requirements",
|
||||
"description": "Deploy OpenPanel on your own infrastructure for complete data ownership. Amplitude is cloud-only with no self-hosting option.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Faster websites and apps",
|
||||
"description": "OpenPanel's 2.3 KB SDK vs Amplitude's 36-96 KB means better performance, faster loads, and improved user experience.",
|
||||
"icon": "zap"
|
||||
},
|
||||
{
|
||||
"title": "Simpler analytics needs",
|
||||
"description": "If you don't need predictive ML models, feature flags, or session replay, OpenPanel gives you core analytics without the bloat.",
|
||||
"icon": "target"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Amplitude to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is Amplitude free?",
|
||||
"answer": "Amplitude offers a free Starter plan with up to 50,000 Monthly Tracked Users (MTUs) and 10 million events. However, it has limited features, no customer support, and you'll need to upgrade quickly as you grow. The Plus plan starts at $49/month for just 1,000 MTUs."
|
||||
},
|
||||
{
|
||||
"question": "Why switch from Amplitude?",
|
||||
"answer": "Common reasons include: pricing that escalates quickly as you grow, complexity that requires dedicated analysts to use effectively, privacy concerns with cookies and consent requirements, no self-hosting option, and a heavy SDK that impacts page performance."
|
||||
},
|
||||
{
|
||||
"question": "Do I need a cookie consent banner with OpenPanel?",
|
||||
"answer": "No. OpenPanel is cookie-free by default and doesn't collect personal data, so no consent banner is required for analytics. Amplitude requires cookies and therefore needs consent under GDPR."
|
||||
},
|
||||
{
|
||||
"question": "Does Amplitude offer EU data hosting?",
|
||||
"answer": "Yes, Amplitude has an EU data center in Frankfurt, Germany. However, you still need cookie consent because Amplitude uses first-party cookies. OpenPanel offers EU-only hosting plus cookie-free tracking."
|
||||
},
|
||||
{
|
||||
"question": "What Amplitude features will I lose?",
|
||||
"answer": "OpenPanel doesn't have feature flags, session replay, predictive cohorts, or the Guides & Surveys product. If you rely heavily on these enterprise features, Amplitude may still be the better fit."
|
||||
},
|
||||
{
|
||||
"question": "How does the SDK size affect my app?",
|
||||
"answer": "OpenPanel's JavaScript SDK is ~2.3 KB gzipped compared to Amplitude's 36 KB (analytics only) or 96 KB (unified). This means faster initial page loads, better Core Web Vitals, and reduced bandwidth costs."
|
||||
},
|
||||
{
|
||||
"question": "Can I import my Amplitude data?",
|
||||
"answer": "Amplitude allows data export on paid plans (to S3, Snowflake, etc.), but there's no direct import into OpenPanel. Most teams run both tools briefly in parallel, then start fresh with OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Is OpenPanel as powerful as Amplitude?",
|
||||
"answer": "OpenPanel covers core product analytics (events, funnels, retention, cohorts, user profiles) and adds web analytics. Amplitude has more advanced features like predictive analytics and feature flags, but many teams don't need that complexity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "Find an alternative to Mixpanel",
|
||||
"url": "/articles/alternatives-to-mixpanel"
|
||||
},
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Mixpanel",
|
||||
"url": "/compare/mixpanel-alternative"
|
||||
},
|
||||
{
|
||||
"name": "PostHog",
|
||||
"url": "/compare/posthog-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
448
apps/public/content/compare/appsflyer-alternative.json
Normal file
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"slug": "appsflyer-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "AppsFlyer Alternative",
|
||||
"description": "Compare OpenPanel with AppsFlyer: pricing, features, and focus. OpenPanel provides product analytics; AppsFlyer specializes in mobile attribution.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "AppsFlyer alternative",
|
||||
"subheading": "Need to understand user behavior, not just ad attribution? OpenPanel provides product analytics—funnels, retention, cohorts, and user profiles—at a fraction of AppsFlyer's cost, without complex enterprise contracts.",
|
||||
"badges": [
|
||||
"Product analytics",
|
||||
"Self-hostable",
|
||||
"Transparent pricing",
|
||||
"Open-source"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "AppsFlyer",
|
||||
"logo": "/logos/appsflyer.svg",
|
||||
"url": "https://www.appsflyer.com",
|
||||
"short_description": "Mobile Measurement Partner (MMP) focused on attributing app installs to marketing campaigns with fraud protection.",
|
||||
"founded": 2011,
|
||||
"headquarters": "San Francisco, CA"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs AppsFlyer: Which is right for you?",
|
||||
"intro": "These tools serve different primary purposes. AppsFlyer focuses on mobile attribution (which ad drove an install); OpenPanel focuses on product analytics (what users do in your app).",
|
||||
"one_liner": "OpenPanel is for product analytics and user behavior; AppsFlyer is for mobile attribution and marketing measurement.",
|
||||
"best_for_openpanel": [
|
||||
"Product teams needing user behavior analytics and funnels",
|
||||
"Startups seeking affordable analytics without enterprise pricing",
|
||||
"Teams wanting self-hosted analytics for data ownership",
|
||||
"Web-first products and SaaS applications",
|
||||
"Open source preference for transparency and auditability"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Mobile marketers needing install attribution",
|
||||
"Teams requiring multi-touch attribution across ad networks",
|
||||
"Apps needing deep linking and deferred deep linking",
|
||||
"Mobile games requiring SKAdNetwork support",
|
||||
"Organizations needing fraud protection for ad spend"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and AppsFlyer compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Primary focus",
|
||||
"openpanel": "Product analytics",
|
||||
"competitor": "Mobile attribution"
|
||||
},
|
||||
{
|
||||
"label": "Pricing model",
|
||||
"openpanel": "Simple event-based",
|
||||
"competitor": "Per-conversion + add-ons"
|
||||
},
|
||||
{
|
||||
"label": "Self-hosting",
|
||||
"openpanel": "Yes, with Docker",
|
||||
"competitor": "No (cloud only)"
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "No (proprietary)"
|
||||
},
|
||||
{
|
||||
"label": "Ad network integrations",
|
||||
"openpanel": "Limited",
|
||||
"competitor": "5,000+ partners"
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "OpenPanel and AppsFlyer serve different primary needs—product analytics vs mobile attribution.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Product analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Event tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Funnels",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "AppsFlyer has attribution funnels, not product funnels"
|
||||
},
|
||||
{
|
||||
"name": "Retention analysis",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "User profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cohorts",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer offers for LTV analysis"
|
||||
},
|
||||
{
|
||||
"name": "User path analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Mobile attribution",
|
||||
"features": [
|
||||
{
|
||||
"name": "Install attribution",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer's core feature"
|
||||
},
|
||||
{
|
||||
"name": "Multi-touch attribution",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Deep linking",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer OneLink"
|
||||
},
|
||||
{
|
||||
"name": "SKAdNetwork support",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Uninstall tracking",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced features",
|
||||
"features": [
|
||||
{
|
||||
"name": "Fraud protection",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer Protect360"
|
||||
},
|
||||
{
|
||||
"name": "A/B testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Audience segmentation",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer Audiences is premium add-on"
|
||||
},
|
||||
{
|
||||
"name": "Cost aggregation",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer Xpend"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Self-hosting",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Open source",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "GDPR compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Data residency options",
|
||||
"openpanel": "Via self-hosting",
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & data",
|
||||
"features": [
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Mobile SDKs",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Ad network integrations",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "5,000+ marketing partners"
|
||||
},
|
||||
{
|
||||
"name": "Data export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "AppsFlyer Data Locker is premium"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK size (mobile)",
|
||||
"openpanel": "~2.3 KB for web",
|
||||
"competitor": "Lightweight (<1% app size)",
|
||||
"notes": "Both maintain small SDK footprints"
|
||||
},
|
||||
{
|
||||
"label": "Supported platforms",
|
||||
"openpanel": [
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js",
|
||||
"Python"
|
||||
],
|
||||
"competitor": [
|
||||
"iOS",
|
||||
"Android",
|
||||
"Unity",
|
||||
"React Native",
|
||||
"Flutter",
|
||||
"Web",
|
||||
"Smart TV",
|
||||
"CTV/OTT"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "No",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Self-hosted deployment",
|
||||
"openpanel": "Docker, simple setup",
|
||||
"competitor": "Not available",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse + PostgreSQL",
|
||||
"competitor": "Proprietary",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "OpenPanel offers simple event-based pricing. AppsFlyer uses per-conversion pricing with expensive add-ons.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Start at $2.50/month for 5,000 events. Self-host for free with unlimited events. All features included at every tier."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "Per-conversion with add-ons",
|
||||
"description": "Zero plan: 12K lifetime conversions free. Growth: $0.05-0.07/conversion. Enterprise: ~$0.03/conversion. Premium add-ons (fraud protection, data locker) cost extra. Enterprise contracts typically $3,000-$100,000+ annually.",
|
||||
"free_tier": "Yes (12K lifetime conversions)",
|
||||
"pricing_url": "https://www.appsflyer.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from AppsFlyer to OpenPanel",
|
||||
"intro": "Consider whether you need attribution (AppsFlyer) or product analytics (OpenPanel). Many teams use both.",
|
||||
"difficulty": "moderate",
|
||||
"estimated_time": "1-2 days for basic setup",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Assess your needs",
|
||||
"description": "Determine if you need mobile attribution (AppsFlyer's strength) or product analytics (OpenPanel's strength). Many teams use both tools."
|
||||
},
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK alongside or instead of AppsFlyer. Available for iOS, Android, React Native, and web."
|
||||
},
|
||||
{
|
||||
"title": "Map events",
|
||||
"description": "Translate your AppsFlyer in-app events to OpenPanel events. Both use similar event-tracking patterns."
|
||||
},
|
||||
{
|
||||
"title": "Set up user identification",
|
||||
"description": "OpenPanel provides detailed user profiles and journey tracking. Add identify() calls to unlock retention and cohort analytics."
|
||||
},
|
||||
{
|
||||
"title": "Configure product analytics",
|
||||
"description": "Set up funnels, retention charts, and dashboards in OpenPanel—features not available in AppsFlyer."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use standard event tracking APIs. OpenPanel focuses on product analytics; AppsFlyer on attribution."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Attribution data from AppsFlyer typically doesn't need migration if using OpenPanel for product analytics."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than AppsFlyer",
|
||||
"intro": "OpenPanel excels when you need product analytics rather than mobile attribution.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Product teams needing behavior analytics",
|
||||
"description": "AppsFlyer tells you which ad brought a user. OpenPanel tells you what that user does in your product—funnels, feature usage, retention patterns.",
|
||||
"icon": "chart"
|
||||
},
|
||||
{
|
||||
"title": "Startups seeking affordable analytics",
|
||||
"description": "AppsFlyer's pricing can reach tens of thousands monthly. OpenPanel offers a generous free tier and simple pricing without expensive add-ons.",
|
||||
"icon": "dollar"
|
||||
},
|
||||
{
|
||||
"title": "Teams wanting self-hosted analytics",
|
||||
"description": "AppsFlyer is cloud-only. OpenPanel can be self-hosted with Docker for complete data ownership and compliance requirements.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Web-first products",
|
||||
"description": "AppsFlyer is designed primarily for mobile attribution. OpenPanel works equally well for web, SaaS, and mobile apps.",
|
||||
"icon": "globe"
|
||||
},
|
||||
{
|
||||
"title": "Open source preference",
|
||||
"description": "If you value transparency and auditability, OpenPanel's open source codebase provides what AppsFlyer's proprietary system cannot.",
|
||||
"icon": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about AppsFlyer and OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Are AppsFlyer and OpenPanel competitors?",
|
||||
"answer": "They solve different problems. AppsFlyer is a Mobile Measurement Partner (MMP) focused on attributing app installs to marketing campaigns. OpenPanel is a product analytics platform focused on understanding user behavior within your product. Many companies use both."
|
||||
},
|
||||
{
|
||||
"question": "Can OpenPanel replace AppsFlyer?",
|
||||
"answer": "It depends on your needs. If you primarily need mobile attribution (knowing which ad campaign drove an install), you need an MMP like AppsFlyer. If you need product analytics (understanding what users do in your app), OpenPanel is the better choice."
|
||||
},
|
||||
{
|
||||
"question": "Why is AppsFlyer so expensive?",
|
||||
"answer": "AppsFlyer charges per attributed conversion ($0.05-0.07 on Growth plan) plus premium add-ons for fraud protection, raw data access, and audience segmentation. Enterprise contracts can cost $50,000-$100,000+ annually. This pricing reflects their position as the market-leading MMP."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have fraud protection like AppsFlyer?",
|
||||
"answer": "No. Fraud protection is specific to mobile attribution—detecting fake installs and click fraud. Since OpenPanel focuses on product analytics rather than attribution, fraud protection isn't applicable."
|
||||
},
|
||||
{
|
||||
"question": "Can OpenPanel do deep linking like AppsFlyer?",
|
||||
"answer": "No. Deep linking (routing users from ads/web to specific app content) is an attribution feature. OpenPanel focuses on analytics after users are in your product."
|
||||
},
|
||||
{
|
||||
"question": "Which is better for mobile games?",
|
||||
"answer": "For user acquisition attribution and ad monetization tracking, AppsFlyer (or similar MMPs) is essential. For understanding player behavior, progression funnels, and retention, OpenPanel provides the product analytics that AppsFlyer lacks. Mobile game studios typically use both."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host AppsFlyer?",
|
||||
"answer": "No. AppsFlyer is a cloud-only SaaS platform with no self-hosting option. OpenPanel can be fully self-hosted using Docker."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "Find an alternative to Mixpanel",
|
||||
"url": "/articles/alternatives-to-mixpanel"
|
||||
},
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Mixpanel",
|
||||
"url": "/compare/mixpanel-alternative"
|
||||
},
|
||||
{
|
||||
"name": "Amplitude",
|
||||
"url": "/compare/amplitude-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
500
apps/public/content/compare/cabin-analytics-alternative.json
Normal file
@@ -0,0 +1,500 @@
|
||||
{
|
||||
"slug": "cabin-analytics-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Cabin Analytics Alternative: Why Teams Choose OpenPanel",
|
||||
"description": "Compare OpenPanel vs Cabin Analytics. Discover why teams choose OpenPanel as their Cabin alternative for deeper product analytics, self-hosting, and mobile SDKs while maintaining environmental responsibility.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Cabin Analytics alternative",
|
||||
"subheading": "Love Cabin's carbon-conscious approach and privacy focus? OpenPanel adds deeper product analytics—self-hosting, mobile SDKs, user identification, funnels, and retention analysis—while staying open source and lightweight.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Self-hostable",
|
||||
"Mobile SDKs",
|
||||
"Product analytics"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Cabin Analytics",
|
||||
"logo": "/logos/cabin.svg",
|
||||
"url": "https://withcabin.com",
|
||||
"short_description": "Privacy-first, carbon-conscious web analytics alternative to Google Analytics with ultra-lightweight tracking.",
|
||||
"founded": 2020,
|
||||
"headquarters": "London, UK"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Cabin: Which is right for you?",
|
||||
"intro": "Both platforms prioritize privacy and lightweight tracking. The key differences are depth of analytics, self-hosting capabilities, and platform support.",
|
||||
"one_liner": "Cabin is simpler and carbon-conscious for web analytics; OpenPanel offers deeper product analytics with mobile SDKs and self-hosting.",
|
||||
"best_for_openpanel": [
|
||||
"Teams requiring self-hosting for data ownership and compliance",
|
||||
"SaaS products needing user-level analytics and retention tracking",
|
||||
"Mobile app analytics with native iOS and Android SDKs",
|
||||
"Teams needing funnel analysis and cohort tracking",
|
||||
"Products requiring A/B testing capabilities"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams prioritizing carbon footprint tracking and environmental responsibility",
|
||||
"Simple web analytics without user identification requirements",
|
||||
"Ultra-lightweight tracking (1KB script) for performance",
|
||||
"Projects needing renewable energy hosting",
|
||||
"Teams wanting flat pricing regardless of traffic"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Cabin compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Self-hosting",
|
||||
"openpanel": "Yes, completely free",
|
||||
"competitor": "No (cloud only)"
|
||||
},
|
||||
{
|
||||
"label": "Product analytics",
|
||||
"openpanel": "Funnels, retention, cohorts",
|
||||
"competitor": "Web analytics only"
|
||||
},
|
||||
{
|
||||
"label": "User identification",
|
||||
"openpanel": "Yes, track individual users",
|
||||
"competitor": "No (anonymous only)"
|
||||
},
|
||||
{
|
||||
"label": "Mobile SDKs",
|
||||
"openpanel": "iOS, Android, React Native",
|
||||
"competitor": "Web only"
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "Partial (client only)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "OpenPanel covers product analytics and web analytics, while Cabin focuses on simple web metrics with unique carbon tracking.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Web analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Page views & visitors",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Traffic sources",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Geographic data",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "OpenPanel offers country, region, city. Cabin country only"
|
||||
},
|
||||
{
|
||||
"name": "Device & browser stats",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "UTM tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Real-time data",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Cabin doesn't offer real-time monitoring"
|
||||
},
|
||||
{
|
||||
"name": "Bounce rate",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Page load time",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Event tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Cabin custom events require Pro plan"
|
||||
},
|
||||
{
|
||||
"name": "Funnels",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Retention analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "User profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Cabin is anonymous only"
|
||||
},
|
||||
{
|
||||
"name": "Cohorts",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "User identification",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Cabin is strictly anonymous by design"
|
||||
},
|
||||
{
|
||||
"name": "User path analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced features",
|
||||
"features": [
|
||||
{
|
||||
"name": "A/B testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Carbon footprint tracking",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Unique Cabin feature"
|
||||
},
|
||||
{
|
||||
"name": "Renewable energy hosting",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Cabin runs on 100% renewable energy"
|
||||
},
|
||||
{
|
||||
"name": "Custom properties",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Public dashboards",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Email reports",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Self-hosting",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cookie-free tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "GDPR compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "CCPA compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "No IP tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "No consent banner required",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & data",
|
||||
"features": [
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Cabin API requires Pro plan"
|
||||
},
|
||||
{
|
||||
"name": "Data export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Mobile SDKs",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Cabin is web-only"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK size (JS)",
|
||||
"openpanel": "~2.3 KB gzipped",
|
||||
"competitor": "~1 KB gzipped",
|
||||
"notes": "Cabin has the lighter script"
|
||||
},
|
||||
{
|
||||
"label": "Supported platforms",
|
||||
"openpanel": [
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"Nuxt",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"PHP",
|
||||
"Go"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"Nuxt",
|
||||
"SvelteKit",
|
||||
"Astro",
|
||||
"Hugo",
|
||||
"Ghost"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "Partial (client only)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Self-hosted deployment",
|
||||
"openpanel": "Docker, simple setup",
|
||||
"competitor": "Not available",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse + PostgreSQL",
|
||||
"competitor": "Proprietary",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data retention",
|
||||
"openpanel": "Unlimited (self-hosted)",
|
||||
"competitor": "30 days free, unlimited Pro",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Both offer generous free tiers with different pricing models—event-based vs flat monthly.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Start at $2.50/month for 5,000 events. Self-host for free with unlimited events. No limits on users, dashboards, or features at any tier."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "Flat monthly pricing",
|
||||
"description": "Free tier: 1 website, 30-day retention, unlimited pageviews. Pro: $19/month for unlimited sites, unlimited retention, custom events, API access.",
|
||||
"free_tier": "Yes (1 site, 30-day retention)",
|
||||
"pricing_url": "https://withcabin.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Cabin to OpenPanel",
|
||||
"intro": "Switching from Cabin to OpenPanel is straightforward for basic tracking, with added capabilities for product analytics.",
|
||||
"difficulty": "easy",
|
||||
"estimated_time": "30 minutes to 1 hour",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK to your application. The script is slightly larger (~2.3KB) but still lightweight."
|
||||
},
|
||||
{
|
||||
"title": "Map custom events",
|
||||
"description": "If you use Cabin custom events (Pro), translate them to OpenPanel format. cabin.event('signup') becomes op.track('signup', {properties})."
|
||||
},
|
||||
{
|
||||
"title": "Add user identification",
|
||||
"description": "Unlike Cabin which is anonymous, OpenPanel supports user identification. Add op.identify() calls to unlock user profiles and retention analysis."
|
||||
},
|
||||
{
|
||||
"title": "Set up funnels and retention",
|
||||
"description": "Create funnels and retention reports in OpenPanel—features not available in Cabin."
|
||||
},
|
||||
{
|
||||
"title": "Remove Cabin script",
|
||||
"description": "Once verified, remove the Cabin tracking script. Both are cookie-free so no consent flow changes needed."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use similar event tracking patterns. OpenPanel adds user identification capabilities not available in Cabin."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Cabin doesn't provide data export on the free plan. Historical data migration requires Pro plan export."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Cabin",
|
||||
"intro": "OpenPanel shines when you need deeper product analytics and platform flexibility beyond simple web tracking.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Teams requiring self-hosting",
|
||||
"description": "Cabin is cloud-only. If you need data on your own infrastructure for compliance or security, OpenPanel provides full self-hosting with Docker.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "SaaS products needing user-level analytics",
|
||||
"description": "Cabin anonymizes all visitors. If you need to track logged-in users, analyze retention, or build cohorts, OpenPanel provides these capabilities.",
|
||||
"icon": "users"
|
||||
},
|
||||
{
|
||||
"title": "Mobile app analytics",
|
||||
"description": "Cabin is web-only. OpenPanel provides native iOS, Android, and React Native SDKs with full product analytics capabilities.",
|
||||
"icon": "smartphone"
|
||||
},
|
||||
{
|
||||
"title": "Teams needing funnel analysis",
|
||||
"description": "Track user flows through signup, onboarding, or purchase processes with OpenPanel's funnel analysis.",
|
||||
"icon": "chart"
|
||||
},
|
||||
{
|
||||
"title": "Products requiring A/B testing",
|
||||
"description": "OpenPanel includes built-in experimentation capabilities. Cabin is analytics-only.",
|
||||
"icon": "flask"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Cabin to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is OpenPanel as privacy-friendly as Cabin?",
|
||||
"answer": "Both are cookie-free by default and GDPR/CCPA compliant. The key difference is that OpenPanel allows optional user identification for product analytics, while Cabin is strictly anonymous. Cabin stores data exclusively in the EU; OpenPanel lets you choose with self-hosting."
|
||||
},
|
||||
{
|
||||
"question": "Why switch from Cabin to OpenPanel?",
|
||||
"answer": "Teams typically switch when they need self-hosting, user identification, mobile SDKs, funnel analysis, or retention tracking. Cabin excels at simple, anonymous web analytics with carbon tracking, but if you need deeper product insights, OpenPanel provides these capabilities."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel track carbon footprint like Cabin?",
|
||||
"answer": "No. Carbon footprint tracking is a unique Cabin feature. If environmental impact tracking is critical for your organization, you may want to keep Cabin or use another tool for this specific capability."
|
||||
},
|
||||
{
|
||||
"question": "Which has the smaller tracking script?",
|
||||
"answer": "Cabin's script is smaller at approximately 1KB (77x smaller than Google Analytics). OpenPanel's script is 2.3KB gzipped. Both are significantly lighter than traditional analytics tools."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host Cabin?",
|
||||
"answer": "No. Cabin is cloud-only with no self-hosting option. Your data is always stored on their EU servers. OpenPanel offers full self-hosting with Docker if you need data on your own infrastructure."
|
||||
},
|
||||
{
|
||||
"question": "How does pricing compare?",
|
||||
"answer": "Cabin uses flat pricing: Free (1 site, 30-day retention) or $19/month Pro (unlimited sites and retention). OpenPanel uses event-based pricing with 10,000 free events/month. For high-traffic sites, Cabin's flat fee may be more cost-effective."
|
||||
},
|
||||
{
|
||||
"question": "Does Cabin support mobile apps?",
|
||||
"answer": "No. Cabin is web-only with no mobile SDKs. OpenPanel provides native iOS, Android, and React Native SDKs for mobile app analytics."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
},
|
||||
{
|
||||
"title": "Cookieless analytics",
|
||||
"url": "/articles/cookieless-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Plausible",
|
||||
"url": "/compare/plausible-alternative"
|
||||
},
|
||||
{
|
||||
"name": "Fathom",
|
||||
"url": "/compare/fathom-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
540
apps/public/content/compare/countly-alternative.json
Normal file
@@ -0,0 +1,540 @@
|
||||
{
|
||||
"slug": "countly-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Countly Alternative: Why Teams Choose OpenPanel",
|
||||
"description": "Compare OpenPanel vs Countly. Discover why teams choose OpenPanel as their Countly alternative for simpler pricing, lighter weight, and modern product analytics while maintaining privacy and self-hosting options.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Countly Alternative",
|
||||
"subheading": "Want Countly's product analytics without the complexity? OpenPanel offers a simpler, more affordable approach to user analytics with self-hosting, mobile SDKs, and modern product analytics - all with transparent pricing.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Simple Pricing",
|
||||
"Lightweight",
|
||||
"MIT License"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Countly",
|
||||
"logo": "/logos/countly.svg",
|
||||
"url": "https://countly.com",
|
||||
"short_description": "All-in-one product analytics platform with engagement features like push notifications, crash reporting, and surveys.",
|
||||
"founded": 2013,
|
||||
"headquarters": "London, UK"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Countly: Which is right for you?",
|
||||
"intro": "Both offer product analytics with self-hosting. Countly is an all-in-one platform with many features. OpenPanel focuses on analytics with simpler pricing.",
|
||||
"one_liner": "Countly is an all-in-one platform with push/crash features; OpenPanel focuses purely on analytics with simpler pricing.",
|
||||
"best_for_openpanel": [
|
||||
"Teams wanting simple analytics without push notifications or crash reporting",
|
||||
"Startups needing affordable, predictable event-based pricing",
|
||||
"Developers wanting true open source (MIT) without commercial restrictions",
|
||||
"Teams preferring lightweight Docker deployment over MongoDB setup"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Mobile apps needing push notifications and crash reporting in one platform",
|
||||
"Teams requiring remote configuration and in-app messaging",
|
||||
"Enterprise customers needing ISO 27001 and SOC2 certifications",
|
||||
"Products needing Flutter, Unity, or desktop (Windows/Mac) SDKs"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Countly compare on key factors.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Pricing Model",
|
||||
"openpanel": "Event-based (10K free/month)",
|
||||
"competitor": "MAU-based (starts at $80/month for 2K MAUs)",
|
||||
"notes": "OpenPanel offers simple event-based pricing with 10,000 free events/month. Countly Flex charges by Monthly Active Users starting at $80/month for 2,000 MAUs, which can get expensive quickly."
|
||||
},
|
||||
{
|
||||
"label": "Open Source License",
|
||||
"openpanel": "MIT License (fully permissive)",
|
||||
"competitor": "AGPL-3.0 (copyleft, non-commercial use only)",
|
||||
"notes": "OpenPanel uses the permissive MIT license allowing any commercial use. Countly Lite uses AGPL-3.0 with modified terms that prohibit commercial use - you must purchase Enterprise for business use."
|
||||
},
|
||||
{
|
||||
"label": "Setup Complexity",
|
||||
"openpanel": "Simple Docker deployment",
|
||||
"competitor": "Complex (MongoDB, Node.js, Nginx)",
|
||||
"notes": "OpenPanel runs in a simple Docker container. Countly requires MongoDB, Node.js, and Nginx with a more complex installation process and upgrade path."
|
||||
},
|
||||
{
|
||||
"label": "Push Notifications",
|
||||
"openpanel": "Not built-in",
|
||||
"competitor": "Built-in rich push notifications",
|
||||
"notes": "Countly includes rich, interactive push notifications for iOS and Android as a core feature. OpenPanel focuses on analytics and doesn't include push notification functionality."
|
||||
},
|
||||
{
|
||||
"label": "Crash Analytics",
|
||||
"openpanel": "Not built-in",
|
||||
"competitor": "Full crash reporting with symbolication",
|
||||
"notes": "Countly provides comprehensive crash and error reporting with symbolication support across all platforms. OpenPanel focuses on product analytics rather than crash reporting."
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "Countly is an all-in-one platform. OpenPanel focuses purely on analytics.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Core Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Page Views & Sessions",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Custom Event Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "User Identification",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support user profiles and identification"
|
||||
},
|
||||
{
|
||||
"name": "Geographic Data",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both offer country and city-level data"
|
||||
},
|
||||
{
|
||||
"name": "Device & Platform Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Real-Time Dashboard",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Custom Dashboards",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Funnel Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Countly calls this 'Funnels' plugin"
|
||||
},
|
||||
{
|
||||
"name": "Retention Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Cohort Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Countly has advanced behavioral cohorts"
|
||||
},
|
||||
{
|
||||
"name": "User Profiles",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "User Flows/Journeys",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Countly has 'Flows' feature for journey visualization"
|
||||
},
|
||||
{
|
||||
"name": "A/B Testing",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Countly A/B testing tied to Remote Config"
|
||||
},
|
||||
{
|
||||
"name": "Revenue Analytics",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Engagement Features",
|
||||
"features": [
|
||||
{
|
||||
"name": "Push Notifications",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly has rich, interactive push for iOS/Android"
|
||||
},
|
||||
{
|
||||
"name": "In-App Messaging",
|
||||
"openpanel": false,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Surveys & NPS",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly includes surveys, ratings, and NPS"
|
||||
},
|
||||
{
|
||||
"name": "Remote Configuration",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly can change app behavior remotely"
|
||||
},
|
||||
{
|
||||
"name": "Automated Workflows",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly has hooks and automated push"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Performance & Debugging",
|
||||
"features": [
|
||||
{
|
||||
"name": "Crash Reporting",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly has full crash analytics with symbolication"
|
||||
},
|
||||
{
|
||||
"name": "Performance Monitoring (APM)",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly monitors network and device performance"
|
||||
},
|
||||
{
|
||||
"name": "Error Tracking",
|
||||
"openpanel": false,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & Compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Self-Hosting Option",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both offer self-hosted deployment"
|
||||
},
|
||||
{
|
||||
"name": "GDPR Compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Consent Management",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Countly has a 'Compliance Hub' feature"
|
||||
},
|
||||
{
|
||||
"name": "Data Export",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "ISO 27001 Certified",
|
||||
"openpanel": false,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "SOC2 Certified",
|
||||
"openpanel": false,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & SDKs",
|
||||
"features": [
|
||||
{
|
||||
"name": "JavaScript/Web SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "iOS SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Android SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "React Native SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Flutter SDK",
|
||||
"openpanel": false,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Unity SDK",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly supports game development with Unity"
|
||||
},
|
||||
{
|
||||
"name": "Desktop SDKs (Windows/Mac)",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Countly has C++ and Windows SDKs"
|
||||
},
|
||||
{
|
||||
"name": "Node.js SDK",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's the technical breakdown.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK Size",
|
||||
"openpanel": "2.3 KB (gzipped)",
|
||||
"competitor": "Varies by platform (larger, full-featured SDKs)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Platforms",
|
||||
"openpanel": [
|
||||
"JavaScript/TypeScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"PHP",
|
||||
"Go",
|
||||
"Rust"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript/Web",
|
||||
"iOS",
|
||||
"Android",
|
||||
"React Native",
|
||||
"Flutter",
|
||||
"Unity",
|
||||
"C++",
|
||||
"Java",
|
||||
"Windows",
|
||||
"Node.js"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes - MIT License",
|
||||
"competitor": "Partial - AGPL (non-commercial only)",
|
||||
"notes": "OpenPanel is fully MIT licensed. Countly Lite is AGPL-3.0 (non-commercial only). Countly Enterprise is proprietary."
|
||||
},
|
||||
{
|
||||
"label": "Self Hosting",
|
||||
"openpanel": "Docker (simple single-container setup)",
|
||||
"competitor": "Docker or install script (requires MongoDB, Node.js, Nginx)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse",
|
||||
"competitor": "MongoDB",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data Retention",
|
||||
"openpanel": "Unlimited (self-hosted), configurable (cloud)",
|
||||
"competitor": "6 months (Flex free), configurable as add-on",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Certifications",
|
||||
"openpanel": "None listed",
|
||||
"competitor": "ISO 27001, SOC2",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Countly's MAU-based pricing with add-ons can get expensive. OpenPanel offers simple event-based pricing.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Simple pricing with 10,000 free events per month. All features included at every tier. Self-host for free with unlimited events."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "MAU-based with feature add-ons",
|
||||
"description": "Lite: Self-hosted, AGPL, non-commercial only. Flex Free: 500 MAUs. Flex Tier 1: $80/month for 2,000 MAUs. Core analytics included, advanced features (A/B testing, surveys, push) are add-ons. Fully-featured Flex can cost $132,000+/year at scale.",
|
||||
"free_tier": "Lite (non-commercial only) or Flex Free (500 MAUs)",
|
||||
"pricing_url": "https://countly.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Countly to OpenPanel",
|
||||
"intro": "Switching from Countly to OpenPanel is straightforward for analytics. You'll need separate tools for push notifications and crash reporting.",
|
||||
"difficulty": "moderate",
|
||||
"estimated_time": "1-4 hours depending on feature usage",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Replace Countly SDK with OpenPanel. OpenPanel's SDK is significantly lighter and simpler to integrate."
|
||||
},
|
||||
{
|
||||
"title": "Map Events and Properties",
|
||||
"description": "Countly events map directly to OpenPanel events. Countly.recordEvent() becomes op.track(). User properties translate similarly."
|
||||
},
|
||||
{
|
||||
"title": "Set Up User Identification",
|
||||
"description": "Both platforms support user identification. Replace Countly's device ID management with OpenPanel's identify() method."
|
||||
},
|
||||
{
|
||||
"title": "Recreate Funnels and Cohorts",
|
||||
"description": "Set up your conversion funnels and user cohorts in OpenPanel. The concepts are similar, though OpenPanel's interface is simpler."
|
||||
},
|
||||
{
|
||||
"title": "Plan for Feature Gaps",
|
||||
"description": "If you use Countly's push notifications, crash reporting, or remote config, you'll need separate tools. OpenPanel focuses purely on analytics."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use event-based tracking with similar APIs."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Most teams start fresh with OpenPanel and run both tools in parallel briefly."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Countly",
|
||||
"intro": "Choose OpenPanel when you want analytics without the complexity and cost of an all-in-one platform.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Teams Wanting Simpler, Cheaper Analytics",
|
||||
"description": "Countly's MAU-based pricing gets expensive quickly, and the feature add-on model adds complexity. OpenPanel offers straightforward event-based pricing with all features included.",
|
||||
"icon": "dollar-sign"
|
||||
},
|
||||
{
|
||||
"title": "Open Source Commercial Use",
|
||||
"description": "Countly Lite's AGPL license prohibits commercial use - you must buy Enterprise. OpenPanel's MIT license allows unlimited commercial use for free.",
|
||||
"icon": "unlock"
|
||||
},
|
||||
{
|
||||
"title": "Lightweight Self-Hosting",
|
||||
"description": "Countly requires MongoDB, Node.js, and Nginx with complex upgrade procedures. OpenPanel runs in a simple Docker container with ClickHouse for faster queries.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Pure Product Analytics Focus",
|
||||
"description": "If you only need analytics without push notifications, crash reporting, or remote config, OpenPanel provides a focused, less complex solution.",
|
||||
"icon": "bar-chart"
|
||||
},
|
||||
{
|
||||
"title": "Startups with Limited Budgets",
|
||||
"description": "Countly's pricing can reach $132,000+/year for full features at scale. OpenPanel's event-based model is more predictable and affordable for growing startups.",
|
||||
"icon": "trending-up"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Countly to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is Countly really open source?",
|
||||
"answer": "Partially. Countly Lite is open source under AGPL-3.0 with modified terms, but it explicitly prohibits commercial use. For business use, you must purchase Countly Enterprise or use Countly Flex (their SaaS). OpenPanel is fully open source under MIT license with no commercial restrictions."
|
||||
},
|
||||
{
|
||||
"question": "Why is OpenPanel cheaper than Countly?",
|
||||
"answer": "OpenPanel uses event-based pricing with 10,000 free events/month. Countly charges by Monthly Active Users (MAUs) starting at $80/month for just 2,000 MAUs, plus additional fees for features like A/B testing, surveys, and push notifications as add-ons."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have push notifications like Countly?",
|
||||
"answer": "No. Countly is an all-in-one platform including push notifications, surveys, and remote configuration. OpenPanel focuses purely on product analytics. You would need separate tools like OneSignal or Firebase for push notifications."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have crash reporting?",
|
||||
"answer": "No. Countly includes comprehensive crash and error reporting with symbolication. OpenPanel focuses on product analytics. For crash reporting, consider tools like Sentry or Crashlytics alongside OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "How does self-hosting compare?",
|
||||
"answer": "OpenPanel runs in a simple Docker container with ClickHouse. Countly requires MongoDB, Node.js, and Nginx with a more complex installation script. Countly's upgrade process can be tedious for older versions."
|
||||
},
|
||||
{
|
||||
"question": "Which has better mobile SDK support?",
|
||||
"answer": "Countly has broader SDK coverage including Flutter, Unity, and desktop (Windows/Mac). OpenPanel supports iOS, Android, and React Native. Choose based on your specific platform needs."
|
||||
},
|
||||
{
|
||||
"question": "Is Countly better for enterprise?",
|
||||
"answer": "Countly Enterprise offers ISO 27001 and SOC2 certifications, SLAs, and direct support - important for large enterprises with strict compliance needs. OpenPanel is better suited for startups and teams prioritizing simplicity and cost."
|
||||
},
|
||||
{
|
||||
"question": "Can I use Countly Lite for my startup?",
|
||||
"answer": "No. Countly Lite's license explicitly states: 'you cannot use Countly Lite for commercial or non-commercial purposes, to provide Countly as a service to your customers.' For business use, you must purchase Countly Enterprise or Flex."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
},
|
||||
{
|
||||
"title": "Self-hosted web analytics",
|
||||
"url": "/articles/self-hosted-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "PostHog",
|
||||
"url": "/compare/posthog-alternative"
|
||||
},
|
||||
{
|
||||
"name": "Mixpanel",
|
||||
"url": "/compare/mixpanel-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
438
apps/public/content/compare/crazy-egg-alternative.json
Normal file
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"slug": "crazy-egg-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Crazy Egg Alternative",
|
||||
"description": "Compare OpenPanel with Crazy Egg: pricing, features, and focus. OpenPanel offers product analytics with mobile SDKs; Crazy Egg excels at heatmaps and A/B testing.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Crazy Egg alternative",
|
||||
"subheading": "Get full product analytics capabilities beyond heatmaps. OpenPanel delivers funnel analysis, retention tracking, mobile SDKs, and user identification—fully open source and self-hostable.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Self-hostable",
|
||||
"Mobile SDKs",
|
||||
"Product analytics"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Crazy Egg",
|
||||
"logo": "/logos/crazy-egg.svg",
|
||||
"url": "https://www.crazyegg.com",
|
||||
"short_description": "Website optimization platform with heatmaps, A/B testing, surveys, and session recordings. Founded by Neil Patel and Hiten Shah in 2005.",
|
||||
"founded": 2005,
|
||||
"headquarters": "La Mirada, CA"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Crazy Egg: Which is right for you?",
|
||||
"intro": "Crazy Egg focuses on visual website optimization with heatmaps and A/B testing. OpenPanel focuses on product analytics with mobile support.",
|
||||
"one_liner": "OpenPanel is for product analytics with mobile SDKs and self-hosting; Crazy Egg is for visual website optimization with heatmaps.",
|
||||
"best_for_openpanel": [
|
||||
"Teams needing mobile app analytics with native SDKs",
|
||||
"Product analytics focus (funnels, retention, cohorts)",
|
||||
"Organizations requiring self-hosting for data ownership",
|
||||
"Open source requirements for transparency",
|
||||
"User-level analytics across platforms"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams requiring comprehensive heatmaps for visual analysis",
|
||||
"Website conversion optimization with A/B testing",
|
||||
"On-site surveys and feedback collection",
|
||||
"Popup CTA creation and management",
|
||||
"Visual website optimization without mobile requirements"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Crazy Egg compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "No (proprietary)"
|
||||
},
|
||||
{
|
||||
"label": "Self-hosting",
|
||||
"openpanel": "Yes, with Docker",
|
||||
"competitor": "No (cloud only)"
|
||||
},
|
||||
{
|
||||
"label": "Mobile SDKs",
|
||||
"openpanel": "iOS, Android, RN, Flutter",
|
||||
"competitor": "Web only"
|
||||
},
|
||||
{
|
||||
"label": "Heatmaps",
|
||||
"openpanel": "Not available",
|
||||
"competitor": "Click, scroll, confetti, overlay"
|
||||
},
|
||||
{
|
||||
"label": "A/B testing",
|
||||
"openpanel": "Not built-in",
|
||||
"competitor": "Visual editor with optimization"
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "OpenPanel focuses on product analytics; Crazy Egg focuses on visual website optimization.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Core analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Event tracking",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Crazy Egg is pageview-focused"
|
||||
},
|
||||
{
|
||||
"name": "Funnels",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Retention analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "User profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cohorts",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Custom dashboards",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Real-time data",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Visual website optimization",
|
||||
"features": [
|
||||
{
|
||||
"name": "Heatmaps",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Crazy Egg's flagship feature"
|
||||
},
|
||||
{
|
||||
"name": "Scroll maps",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Confetti reports",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "A/B testing",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Visual editor"
|
||||
},
|
||||
{
|
||||
"name": "Session recordings",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Popup CTAs",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Feedback & surveys",
|
||||
"features": [
|
||||
{
|
||||
"name": "On-site surveys",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "NPS scoring",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Self-hosting",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Open source",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "GDPR compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cookie-free option",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "HIPAA compliant",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & data",
|
||||
"features": [
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Crazy Egg has limited API"
|
||||
},
|
||||
{
|
||||
"name": "Data export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Mobile SDKs",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Crazy Egg is web only"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK size (JS)",
|
||||
"openpanel": "~2.3 KB gzipped",
|
||||
"competitor": "Async script (minimal impact)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Supported platforms",
|
||||
"openpanel": [
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Flutter"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript (web only)"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "No",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Self-hosted deployment",
|
||||
"openpanel": "Docker, simple setup",
|
||||
"competitor": "Not available",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse + PostgreSQL",
|
||||
"competitor": "Proprietary",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data retention",
|
||||
"openpanel": "Unlimited (self-hosted)",
|
||||
"competitor": "6 months to 2 years",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Both offer generous free tiers with different pricing models—event-based vs pageview-based.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Start at $2.50/month for 5,000 events. Self-host for free with unlimited events. All features included at every tier."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "Pageview-based, annual billing",
|
||||
"description": "Free plan with limited features. Starter: $29/month for 5,000 pageviews. Plus: $99/month for 150,000 pageviews with A/B testing. Enterprise: $599/month for 1M pageviews.",
|
||||
"free_tier": "Yes (limited features)",
|
||||
"pricing_url": "https://www.crazyegg.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Crazy Egg to OpenPanel",
|
||||
"intro": "Consider if heatmaps and A/B testing are critical before switching, as these are Crazy Egg's core strengths.",
|
||||
"difficulty": "easy",
|
||||
"estimated_time": "1-2 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK to your website. The SDK is lightweight and loads asynchronously."
|
||||
},
|
||||
{
|
||||
"title": "Define event tracking strategy",
|
||||
"description": "Unlike Crazy Egg's pageview focus, OpenPanel tracks custom events. Define key actions using track() calls."
|
||||
},
|
||||
{
|
||||
"title": "Set up user identification",
|
||||
"description": "Crazy Egg tracks anonymous sessions. With OpenPanel, identify users with identify() to track individual journeys."
|
||||
},
|
||||
{
|
||||
"title": "Build funnels and dashboards",
|
||||
"description": "Recreate conversion tracking from Crazy Egg as OpenPanel funnels."
|
||||
},
|
||||
{
|
||||
"title": "Remove Crazy Egg script",
|
||||
"description": "Once verified, remove the Crazy Egg tracking code. Note: You will lose heatmaps, A/B testing, and surveys."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": false,
|
||||
"notes": "Different tracking philosophies: Crazy Egg is pageview/visual focused, OpenPanel is event-based."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Crazy Egg data export depends on your plan. Historical heatmap data cannot be migrated."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Crazy Egg",
|
||||
"intro": "OpenPanel excels when you need product analytics beyond visual website optimization.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Mobile app analytics",
|
||||
"description": "Crazy Egg is web-only. OpenPanel offers native SDKs for iOS, Android, React Native, and Flutter.",
|
||||
"icon": "smartphone"
|
||||
},
|
||||
{
|
||||
"title": "Product analytics beyond heatmaps",
|
||||
"description": "OpenPanel provides funnel analysis, retention cohorts, user profiles, and path analysis.",
|
||||
"icon": "chart"
|
||||
},
|
||||
{
|
||||
"title": "Self-hosting requirements",
|
||||
"description": "Crazy Egg is cloud-only. OpenPanel offers Docker deployment for complete data control.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Open source requirements",
|
||||
"description": "Full source code transparency under MIT license. Crazy Egg is proprietary.",
|
||||
"icon": "code"
|
||||
},
|
||||
{
|
||||
"title": "User-level analytics",
|
||||
"description": "Track individual users and their complete journey across sessions. Crazy Egg focuses on aggregate pageview data.",
|
||||
"icon": "users"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Crazy Egg to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "What will I lose switching from Crazy Egg to OpenPanel?",
|
||||
"answer": "Crazy Egg's core features are heatmaps, A/B testing, on-site surveys, and popup CTAs. OpenPanel does not offer these features—it focuses on product analytics instead."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have heatmaps like Crazy Egg?",
|
||||
"answer": "No. OpenPanel is focused on event-based product analytics (funnels, retention, user identification) rather than visual website optimization."
|
||||
},
|
||||
{
|
||||
"question": "How does pricing compare?",
|
||||
"answer": "Crazy Egg pricing starts at $29/month for 5,000 pageviews (annual billing). OpenPanel offers 10,000 events/month free, then $0.00005/event. For similar traffic, OpenPanel is typically more affordable."
|
||||
},
|
||||
{
|
||||
"question": "Can I track mobile apps with Crazy Egg?",
|
||||
"answer": "No. Crazy Egg is designed exclusively for website analytics. OpenPanel offers native SDKs for iOS, Android, React Native, and Flutter."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have A/B testing like Crazy Egg?",
|
||||
"answer": "No. Crazy Egg includes a visual A/B testing editor as a core feature. OpenPanel focuses on analytics rather than experimentation."
|
||||
},
|
||||
{
|
||||
"question": "Is Crazy Egg open source?",
|
||||
"answer": "No. Crazy Egg is proprietary closed-source. OpenPanel is fully open source under the MIT license."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Microsoft Clarity",
|
||||
"url": "/compare/microsoft-clarity-alternative"
|
||||
},
|
||||
{
|
||||
"name": "FullStory",
|
||||
"url": "/compare/fullstory-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
166
apps/public/content/compare/ensure.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface FileStructure {
|
||||
filename: string;
|
||||
rootKeys: string[];
|
||||
structureKey: string; // normalized structure identifier
|
||||
hasContent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function analyzeJsonFiles(): Promise<void> {
|
||||
const dirPath = join(import.meta.dirname || __dirname);
|
||||
const files = await readdir(dirPath);
|
||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
console.log(`\n📊 Analyzing ${jsonFiles.length} JSON files...\n`);
|
||||
|
||||
const structures: FileStructure[] = [];
|
||||
|
||||
// Read and analyze each JSON file
|
||||
for (const filename of jsonFiles) {
|
||||
const filePath = join(dirPath, filename);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
|
||||
if (!content.trim()) {
|
||||
structures.push({
|
||||
filename,
|
||||
rootKeys: [],
|
||||
structureKey: 'empty',
|
||||
hasContent: false,
|
||||
error: 'File is empty',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = JSON.parse(content);
|
||||
const rootKeys = Object.keys(data).sort();
|
||||
const structureKey = rootKeys.join('|');
|
||||
|
||||
structures.push({
|
||||
filename,
|
||||
rootKeys,
|
||||
structureKey,
|
||||
hasContent: true,
|
||||
});
|
||||
} catch (error) {
|
||||
structures.push({
|
||||
filename,
|
||||
rootKeys: [],
|
||||
structureKey: 'error',
|
||||
hasContent: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Group files by structure
|
||||
const groups = new Map<string, FileStructure[]>();
|
||||
for (const structure of structures) {
|
||||
const key = structure.structureKey;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(structure);
|
||||
}
|
||||
|
||||
// Display results
|
||||
const separator = '='.repeat(80);
|
||||
console.log(separator);
|
||||
console.log('📋 ALL ROOT KEYS FOUND ACROSS ALL FILES:');
|
||||
console.log(separator);
|
||||
|
||||
const allKeys = new Set<string>();
|
||||
structures.forEach((s) => {
|
||||
s.rootKeys.forEach((k) => allKeys.add(k));
|
||||
});
|
||||
|
||||
const sortedKeys = Array.from(allKeys).sort();
|
||||
sortedKeys.forEach((key) => {
|
||||
const filesWithKey = structures.filter((s) => s.rootKeys.includes(key));
|
||||
console.log(` ✓ ${key.padEnd(30)} (in ${filesWithKey.length} files)`);
|
||||
});
|
||||
|
||||
console.log(`\n${separator}`);
|
||||
console.log('📦 FILES GROUPED BY STRUCTURE:');
|
||||
console.log(separator);
|
||||
|
||||
const sortedGroups = Array.from(groups.entries()).sort(
|
||||
(a, b) => b[1].length - a[1].length,
|
||||
);
|
||||
|
||||
sortedGroups.forEach(([structureKey, files], index) => {
|
||||
const fileCount = files.length;
|
||||
const plural = fileCount > 1 ? 's' : '';
|
||||
console.log(`\n🔹 Group ${index + 1} (${fileCount} file${plural}):`);
|
||||
console.log(` Structure: ${structureKey || '(empty/error)'}`);
|
||||
|
||||
if (files[0].rootKeys.length > 0) {
|
||||
console.log(` Root keys: ${files[0].rootKeys.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(' Files:');
|
||||
files.forEach((file) => {
|
||||
const status = file.hasContent ? '✓' : file.error ? '✗' : '○';
|
||||
console.log(` ${status} ${file.filename}`);
|
||||
if (file.error) {
|
||||
console.log(` Error: ${file.error}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Validation summary
|
||||
console.log(`\n${separator}`);
|
||||
console.log('✅ VALIDATION SUMMARY:');
|
||||
console.log(separator);
|
||||
|
||||
const validFiles = structures.filter((s) => s.hasContent && !s.error);
|
||||
const emptyFiles = structures.filter((s) => !s.hasContent && !s.error);
|
||||
const errorFiles = structures.filter((s) => s.error);
|
||||
|
||||
console.log(` Total files: ${structures.length}`);
|
||||
const validCount = validFiles.length;
|
||||
const emptyCount = emptyFiles.length;
|
||||
const errorCount = errorFiles.length;
|
||||
console.log(` ✓ Valid JSON files: ${validCount}`);
|
||||
console.log(` ○ Empty files: ${emptyCount}`);
|
||||
console.log(` ✗ Files with errors: ${errorCount}`);
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
const uniqueStructures = new Set(validFiles.map((s) => s.structureKey));
|
||||
const uniqueCount = uniqueStructures.size;
|
||||
console.log(`\n 📊 Unique structures: ${uniqueCount}`);
|
||||
|
||||
if (uniqueCount > 1) {
|
||||
console.log('\n ⚠️ WARNING: Files have inconsistent structures!');
|
||||
console.log(` Found ${uniqueCount} different structure(s).`);
|
||||
} else {
|
||||
console.log('\n ✓ All valid files have consistent structure!');
|
||||
}
|
||||
}
|
||||
|
||||
// Show structure differences in detail
|
||||
if (sortedGroups.length > 1) {
|
||||
console.log(`\n${separator}`);
|
||||
console.log('🔍 STRUCTURE DIFFERENCES:');
|
||||
console.log(separator);
|
||||
|
||||
sortedGroups.forEach(([structureKey, files], index) => {
|
||||
if (structureKey === 'empty' || structureKey === 'error') return;
|
||||
|
||||
const groupNum = index + 1;
|
||||
console.log(`\nGroup ${groupNum} structure:`);
|
||||
const sample = files[0];
|
||||
sample.rootKeys.forEach((key) => {
|
||||
console.log(` - ${key}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${separator}\n`);
|
||||
}
|
||||
|
||||
// Run the analysis
|
||||
analyzeJsonFiles().catch(console.error);
|
||||
493
apps/public/content/compare/fathom-alternative.json
Normal file
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"slug": "fathom-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Fathom Alternative: Why Teams Choose OpenPanel",
|
||||
"description": "Compare OpenPanel vs Fathom Analytics. Discover why teams choose OpenPanel as their Fathom alternative for product analytics, user identification, and self-hosting while maintaining privacy-first principles.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Fathom Alternative",
|
||||
"subheading": "Love Fathom's simplicity and privacy focus? OpenPanel adds product analytics capabilities - funnels, cohorts, retention, and user identification - plus self-hosting options and a free tier.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Privacy-first",
|
||||
"Self-hostable",
|
||||
"Free Tier"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Fathom Analytics",
|
||||
"logo": "/logos/fathom.svg",
|
||||
"url": "https://usefathom.com",
|
||||
"short_description": "Privacy-focused, simple Google Analytics alternative for websites with beautiful dashboard and EU-based hosting.",
|
||||
"founded": 2018,
|
||||
"headquarters": "Canada"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Fathom: Which is right for you?",
|
||||
"intro": "Both are privacy-focused analytics platforms. Fathom focuses on simple web traffic metrics. OpenPanel adds product analytics with user identification and self-hosting.",
|
||||
"one_liner": "Fathom excels at simple web analytics; OpenPanel adds product analytics, self-hosting, and a free tier.",
|
||||
"best_for_openpanel": [
|
||||
"SaaS products needing user-level analytics and retention tracking",
|
||||
"Teams wanting self-hosting for complete data control",
|
||||
"Startups needing a free tier to get started",
|
||||
"Mobile app developers needing native iOS, Android, React Native SDKs"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Simple content sites and blogs wanting traffic stats only",
|
||||
"Teams valuing Fathom's beautiful, minimal dashboard design",
|
||||
"Users wanting excellent Google Analytics import tool",
|
||||
"Organizations committed to paying for sustainable software"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and Fathom compare on key factors.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Analytics Depth",
|
||||
"openpanel": "Web + Product Analytics",
|
||||
"competitor": "Web Analytics Only",
|
||||
"notes": "OpenPanel combines web analytics with product analytics including funnels, retention, cohorts, and user profiles. Fathom focuses on simple website traffic metrics and conversions."
|
||||
},
|
||||
{
|
||||
"label": "Self-Hosting",
|
||||
"openpanel": "Full self-hosting available",
|
||||
"competitor": "Cloud-only (no self-host option)",
|
||||
"notes": "OpenPanel offers full self-hosting in a single Docker container. Fathom is cloud-only; their legacy open-source version (Fathom Lite) uses cookies and is no longer actively developed."
|
||||
},
|
||||
{
|
||||
"label": "Free Tier",
|
||||
"openpanel": "10,000 events/month free",
|
||||
"competitor": "No free tier (7-day trial only)",
|
||||
"notes": "OpenPanel offers a generous free tier. Fathom has no free option - just a 7-day trial before requiring a $15/month minimum subscription."
|
||||
},
|
||||
{
|
||||
"label": "User Identification",
|
||||
"openpanel": "Yes - Track individual users",
|
||||
"competitor": "No - Anonymous aggregate only",
|
||||
"notes": "OpenPanel lets you identify and track individual users across sessions. Fathom uses anonymization and doesn't track individuals."
|
||||
},
|
||||
{
|
||||
"label": "Cookie-Free",
|
||||
"openpanel": "Yes (by default)",
|
||||
"competitor": "Yes (by default)",
|
||||
"notes": "Both platforms are cookie-free by default and don't require consent banners under GDPR for basic analytics."
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "Both are privacy-focused, but with different capabilities and depths.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Web Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Page Views & Visitors",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Traffic Sources",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Geographic Data",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both offer country and region-level data"
|
||||
},
|
||||
{
|
||||
"name": "Device & Browser",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "UTM Campaign Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Real-Time Dashboard",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Custom Event Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Fathom events count toward pageview quota"
|
||||
},
|
||||
{
|
||||
"name": "Funnel Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom tracks simple conversions but no multi-step funnels"
|
||||
},
|
||||
{
|
||||
"name": "Retention Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom's anonymous model doesn't support retention"
|
||||
},
|
||||
{
|
||||
"name": "User Profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom intentionally doesn't track individual users"
|
||||
},
|
||||
{
|
||||
"name": "Cohort Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom provides aggregate data only"
|
||||
},
|
||||
{
|
||||
"name": "User Path Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom shows top pages but not user journeys"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced Features",
|
||||
"features": [
|
||||
{
|
||||
"name": "A/B Testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom is analytics-only, no experimentation"
|
||||
},
|
||||
{
|
||||
"name": "Revenue Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Fathom supports event values in cents"
|
||||
},
|
||||
{
|
||||
"name": "Email Reports",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both offer scheduled email reports"
|
||||
},
|
||||
{
|
||||
"name": "Dashboard Sharing",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support public and private dashboard sharing"
|
||||
},
|
||||
{
|
||||
"name": "Google Analytics Import",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Fathom has a well-regarded GA importer"
|
||||
},
|
||||
{
|
||||
"name": "Ad-Blocker Bypass",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support custom domains to bypass blockers"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & Compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Cookie-Free by Default",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both are cookieless by default"
|
||||
},
|
||||
{
|
||||
"name": "No Consent Banner Required",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both claim no consent needed for basic analytics"
|
||||
},
|
||||
{
|
||||
"name": "GDPR Compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "EU Data Residency",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Fathom has EU Isolation enabled by default for all customers"
|
||||
},
|
||||
{
|
||||
"name": "Self-Hosting Option",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Fathom is cloud-only; Fathom Lite is legacy and uses cookies"
|
||||
},
|
||||
{
|
||||
"name": "Data Processing Agreement",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations",
|
||||
"features": [
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Fathom has a comprehensive API"
|
||||
},
|
||||
{
|
||||
"name": "Data Export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "CSV export available on both"
|
||||
},
|
||||
{
|
||||
"name": "WordPress Plugin",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Fathom has official WordPress plugin"
|
||||
},
|
||||
{
|
||||
"name": "NPM Package",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "fathom-client package available"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's the technical breakdown.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK Size",
|
||||
"openpanel": "2.3 KB (gzipped)",
|
||||
"competitor": "~2 KB (gzipped)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Platforms",
|
||||
"openpanel": [
|
||||
"JavaScript/TypeScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"PHP",
|
||||
"Go",
|
||||
"Rust"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript (browser)",
|
||||
"WordPress",
|
||||
"Webflow",
|
||||
"Next.js",
|
||||
"Vue.js",
|
||||
"Ghost",
|
||||
"ConvertKit",
|
||||
"Various CMS integrations"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes - MIT License",
|
||||
"competitor": "No - Closed source",
|
||||
"notes": "Fathom Analytics is closed-source. Fathom Lite (legacy) is open-source but limited and uses cookies."
|
||||
},
|
||||
{
|
||||
"label": "Self Hosting",
|
||||
"openpanel": "Docker (simple single-container setup)",
|
||||
"competitor": "Not available (cloud-only service)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse",
|
||||
"competitor": "Proprietary cloud infrastructure (serverless on AWS)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data Retention",
|
||||
"openpanel": "Unlimited (self-hosted), configurable (cloud)",
|
||||
"competitor": "Forever (as long as you're a customer)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Language",
|
||||
"openpanel": "TypeScript/Node.js",
|
||||
"competitor": "Unknown (closed-source)",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "Fathom has no free tier. OpenPanel offers free cloud and self-hosting.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Simple pricing with 10,000 free events per month. All features included at every tier. Self-host for free with unlimited events."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "Pageview-based with 7-day trial",
|
||||
"description": "100K pageviews: $15/month ($150/year). Up to 50 sites included. All features included. Custom events count as pageviews. Annual billing saves 2 months. Fathom has no free tier and never does discounts.",
|
||||
"free_tier": "No free tier (7-day trial only)",
|
||||
"pricing_url": "https://usefathom.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Fathom to OpenPanel",
|
||||
"intro": "Both use lightweight scripts and simple event tracking, making migration straightforward.",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK to your application. Both use lightweight scripts, so the transition is straightforward."
|
||||
},
|
||||
{
|
||||
"title": "Map Events",
|
||||
"description": "Fathom's trackEvent() becomes OpenPanel's op.track(). Event values translate directly."
|
||||
},
|
||||
{
|
||||
"title": "Set Up User Identification (New)",
|
||||
"description": "Unlike Fathom, OpenPanel can identify users. Add op.identify() calls to unlock retention, cohorts, and user profiles."
|
||||
},
|
||||
{
|
||||
"title": "Configure Product Analytics (New)",
|
||||
"description": "Set up funnels, retention reports, and cohorts. These features aren't available in Fathom."
|
||||
},
|
||||
{
|
||||
"title": "Remove Fathom Script",
|
||||
"description": "Once verified, remove the Fathom tracking script. Both are cookie-free so no consent changes needed."
|
||||
}
|
||||
],
|
||||
"difficulty": "easy",
|
||||
"estimated_time": "30 minutes to 2 hours",
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both use simple event tracking APIs. Fathom's trackEvent() becomes OpenPanel's op.track()."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Most teams start fresh with OpenPanel and run both tools in parallel briefly."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Fathom",
|
||||
"intro": "Choose OpenPanel when you need product analytics beyond simple web traffic.",
|
||||
"items": [
|
||||
{
|
||||
"title": "SaaS Products Needing User Analytics",
|
||||
"description": "Fathom shows aggregate website traffic. If you need to understand individual user journeys, retention, and behavior within your product, OpenPanel adds these capabilities while staying privacy-friendly.",
|
||||
"icon": "users"
|
||||
},
|
||||
{
|
||||
"title": "Teams Who Want Self-Hosting",
|
||||
"description": "Fathom is cloud-only with no self-hosting option. If data sovereignty or compliance requirements mean you need to host your own analytics, OpenPanel provides full self-hosting in a single Docker container.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Startups Needing a Free Tier",
|
||||
"description": "Fathom has no free option, requiring a minimum $15/month commitment. OpenPanel offers 10,000 free events monthly, perfect for early-stage products or side projects.",
|
||||
"icon": "trending-up"
|
||||
},
|
||||
{
|
||||
"title": "Mobile App Analytics",
|
||||
"description": "Fathom is designed for websites and doesn't provide native mobile SDKs. OpenPanel offers native iOS, Android, and React Native SDKs with full product analytics capabilities.",
|
||||
"icon": "smartphone"
|
||||
},
|
||||
{
|
||||
"title": "Teams Needing Funnel Analysis",
|
||||
"description": "Fathom tracks simple conversions but doesn't offer multi-step funnel analysis. If understanding conversion paths matters, OpenPanel provides detailed funnel visualization.",
|
||||
"icon": "filter"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from Fathom to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is OpenPanel as privacy-friendly as Fathom?",
|
||||
"answer": "Yes! Both are cookie-free by default and don't require consent banners. The key difference is that OpenPanel allows optional user identification for product analytics, while Fathom is strictly anonymous. You control whether to identify users in OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Why switch from Fathom to OpenPanel?",
|
||||
"answer": "Teams typically switch when they need: 1) Product analytics (funnels, retention, cohorts), 2) User identification, 3) Self-hosting options, or 4) A free tier for starting out. Fathom excels at simple web analytics but doesn't offer these capabilities."
|
||||
},
|
||||
{
|
||||
"question": "Does Fathom have a free tier?",
|
||||
"answer": "No. Fathom offers a 7-day free trial, then requires a minimum $15/month subscription. They intentionally don't offer a free tier or discounts. OpenPanel provides 10,000 free events monthly."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host Fathom?",
|
||||
"answer": "No. Fathom Analytics is cloud-only. Their legacy open-source version (Fathom Lite) exists but uses cookies, has limited features, and is no longer actively developed. OpenPanel offers full self-hosting in a single Docker container."
|
||||
},
|
||||
{
|
||||
"question": "Which has the smaller tracking script?",
|
||||
"answer": "They're similar. Fathom's script is approximately 2KB, and OpenPanel's is 2.3KB. Both are significantly smaller than Google Analytics (45KB+) or other enterprise tools."
|
||||
},
|
||||
{
|
||||
"question": "Can Fathom track mobile apps?",
|
||||
"answer": "Not natively. Fathom is designed for web analytics and doesn't provide mobile SDKs. You could send events via their API, but there are no mobile-specific features. OpenPanel offers native iOS, Android, and React Native SDKs."
|
||||
},
|
||||
{
|
||||
"question": "What will I lose switching from Fathom?",
|
||||
"answer": "Fathom's Google Analytics import tool is well-regarded if you're migrating historical data. Their EU Isolation feature is enabled by default for all customers. However, you'll gain product analytics, self-hosting options, and a free tier that Fathom doesn't offer."
|
||||
},
|
||||
{
|
||||
"question": "Is Fathom or OpenPanel better for blogs/content sites?",
|
||||
"answer": "For simple content sites where you just need traffic stats, Fathom's simplicity is excellent and their GA importer helps with transitions. If you're running a SaaS, e-commerce, or any product where understanding user behavior matters, OpenPanel's product analytics features provide more value."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
},
|
||||
{
|
||||
"title": "Cookieless analytics",
|
||||
"url": "/articles/cookieless-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "Plausible",
|
||||
"url": "/compare/plausible-alternative"
|
||||
},
|
||||
{
|
||||
"name": "Simple Analytics",
|
||||
"url": "/compare/simple-analytics-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
448
apps/public/content/compare/fullstory-alternative.json
Normal file
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"slug": "fullstory-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "FullStory Alternative",
|
||||
"description": "Compare OpenPanel with FullStory: pricing, privacy, self-hosting, and features. OpenPanel offers product analytics with transparent pricing and self-hosting.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "FullStory alternative",
|
||||
"subheading": "Get powerful product analytics without FullStory's enterprise pricing. OpenPanel delivers funnel analysis, retention tracking, and user identification—fully open source and self-hostable with transparent pricing.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Self-hostable",
|
||||
"Transparent pricing",
|
||||
"Product analytics"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "FullStory",
|
||||
"logo": "/logos/fullstory.svg",
|
||||
"url": "https://www.fullstory.com",
|
||||
"short_description": "Digital Experience Intelligence (DXI) platform known for pixel-perfect session replay and frustration signal detection.",
|
||||
"founded": 2014,
|
||||
"headquarters": "Atlanta, GA"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs FullStory: Which is right for you?",
|
||||
"intro": "Both platforms provide analytics and session replay, but FullStory focuses on visual debugging while OpenPanel emphasizes product analytics.",
|
||||
"one_liner": "OpenPanel is open source with transparent pricing for product analytics; FullStory excels at session replay with enterprise pricing.",
|
||||
"best_for_openpanel": [
|
||||
"Teams needing open source analytics for transparency",
|
||||
"Organizations requiring self-hosting for data sovereignty",
|
||||
"Budget-conscious teams seeking transparent pricing",
|
||||
"Product analytics focus over visual UX debugging",
|
||||
"Teams avoiding vendor lock-in"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams requiring pixel-perfect session replay",
|
||||
"Organizations needing sophisticated frustration detection",
|
||||
"Enterprise teams with budget for premium features",
|
||||
"UX teams requiring heatmaps and DevTools integration",
|
||||
"Companies needing AI-powered session summaries"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Here's how OpenPanel and FullStory compare on the factors that matter most.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "No (proprietary)"
|
||||
},
|
||||
{
|
||||
"label": "Self-hosting",
|
||||
"openpanel": "Yes, with Docker",
|
||||
"competitor": "No (cloud only)"
|
||||
},
|
||||
{
|
||||
"label": "Transparent pricing",
|
||||
"openpanel": "Public pricing, free tier",
|
||||
"competitor": "Sales-required pricing"
|
||||
},
|
||||
{
|
||||
"label": "Session replay",
|
||||
"openpanel": "Basic",
|
||||
"competitor": "Pixel-perfect with DevTools"
|
||||
},
|
||||
{
|
||||
"label": "Heatmaps",
|
||||
"openpanel": "Not available",
|
||||
"competitor": "Click, scroll, engagement"
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "OpenPanel focuses on product analytics; FullStory excels at session replay and visual debugging.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Core analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Event tracking",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "FullStory uses autocapture"
|
||||
},
|
||||
{
|
||||
"name": "Funnels",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Retention analysis",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "FullStory on Advanced/Enterprise plans"
|
||||
},
|
||||
{
|
||||
"name": "User profiles",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cohorts",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Custom dashboards",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Real-time data",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Visual analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "FullStory's core strength"
|
||||
},
|
||||
{
|
||||
"name": "Heatmaps",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Rage click detection",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Frustration signals",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "FullStory's proprietary feature"
|
||||
},
|
||||
{
|
||||
"name": "DevTools integration",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "AI session summaries",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "StoryAI"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced features",
|
||||
"features": [
|
||||
{
|
||||
"name": "A/B testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Autocapture",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Data export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "FullStory Data Direct"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Privacy & compliance",
|
||||
"features": [
|
||||
{
|
||||
"name": "Self-hosting",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Open source",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "GDPR compliant",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Cookie-free option",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "SOC 2 certified",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Type II"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integrations & data",
|
||||
"features": [
|
||||
{
|
||||
"name": "REST API",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Data warehouse sync",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"name": "Mobile SDKs",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "For developers evaluating analytics tools, here's how the implementations compare.",
|
||||
"items": [
|
||||
{
|
||||
"label": "SDK size (JS)",
|
||||
"openpanel": "~2.3 KB gzipped",
|
||||
"competitor": "~10+ KB (autocapture)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Supported platforms",
|
||||
"openpanel": [
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Next.js",
|
||||
"Vue",
|
||||
"React Native",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Node.js"
|
||||
],
|
||||
"competitor": [
|
||||
"JavaScript",
|
||||
"iOS",
|
||||
"Android",
|
||||
"React Native",
|
||||
"Flutter"
|
||||
],
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open source",
|
||||
"openpanel": "Yes (MIT)",
|
||||
"competitor": "No",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Self-hosted deployment",
|
||||
"openpanel": "Docker, simple setup",
|
||||
"competitor": "Not available",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse + PostgreSQL",
|
||||
"competitor": "Proprietary",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data retention",
|
||||
"openpanel": "Unlimited (self-hosted)",
|
||||
"competitor": "2-60 months depending on plan",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "OpenPanel offers transparent pricing. FullStory requires sales conversations with enterprise pricing.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Start at $2.50/month for 5,000 events. Self-host for free with unlimited events. All features included at every tier."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "Session-based, sales-required",
|
||||
"description": "14-day trial with 5,000 sessions. Business/Advanced/Enterprise plans require custom quotes. Pricing reportedly starts at $12,000-50,000+/year for most teams.",
|
||||
"free_tier": "14-day trial (limited)",
|
||||
"pricing_url": "https://www.fullstory.com/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from FullStory to OpenPanel",
|
||||
"intro": "Switching means trading session replay features for open source, self-hosting, and transparent pricing.",
|
||||
"difficulty": "moderate",
|
||||
"estimated_time": "2-4 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "Add the OpenPanel SDK to your application. The SDK is lightweight (~2.3KB) compared to FullStory's autocapture script."
|
||||
},
|
||||
{
|
||||
"title": "Map event tracking",
|
||||
"description": "FullStory uses autocapture for most events. With OpenPanel, you'll define explicit event tracking using track() calls."
|
||||
},
|
||||
{
|
||||
"title": "Transfer user identification",
|
||||
"description": "FullStory's identify() maps to OpenPanel's identify(). Transfer your user ID and custom user variables."
|
||||
},
|
||||
{
|
||||
"title": "Set up funnels and analytics",
|
||||
"description": "Recreate your FullStory funnels in OpenPanel. Build custom dashboards for key metrics."
|
||||
},
|
||||
{
|
||||
"title": "Remove FullStory script",
|
||||
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to session replay and heatmaps."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": true,
|
||||
"notes": "Both support similar identification and event tracking patterns. Main difference is autocapture vs explicit tracking."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "FullStory's session data is proprietary. Contact us if you need assistance with data migration."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than FullStory",
|
||||
"intro": "OpenPanel excels when you need product analytics with data ownership and transparent pricing.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Teams needing open source analytics",
|
||||
"description": "FullStory is proprietary with no source code access. OpenPanel provides full source code transparency under MIT license.",
|
||||
"icon": "code"
|
||||
},
|
||||
{
|
||||
"title": "Self-hosting requirements",
|
||||
"description": "FullStory is cloud-only. OpenPanel can be deployed on your servers for complete data control.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Budget-conscious teams",
|
||||
"description": "FullStory's enterprise pricing (reportedly $12,000-50,000+/year) is prohibitive. OpenPanel offers transparent pricing starting at $2.50/month.",
|
||||
"icon": "dollar"
|
||||
},
|
||||
{
|
||||
"title": "Product analytics focus",
|
||||
"description": "If your primary need is product analytics—funnels, retention, cohorts—rather than visual debugging, OpenPanel provides these at a fraction of the cost.",
|
||||
"icon": "chart"
|
||||
},
|
||||
{
|
||||
"title": "Avoiding vendor lock-in",
|
||||
"description": "FullStory's proprietary platform creates vendor lock-in. OpenPanel's open source nature prevents dependency on a single vendor.",
|
||||
"icon": "lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about switching from FullStory to OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "What will I lose switching from FullStory to OpenPanel?",
|
||||
"answer": "FullStory's core strengths are session replay, heatmaps, and frustration signals (rage clicks, dead clicks). OpenPanel has basic session replay but lacks heatmaps and FullStory's sophisticated frustration detection. If visual UX debugging is critical, consider keeping FullStory for that use case."
|
||||
},
|
||||
{
|
||||
"question": "Is OpenPanel as feature-rich as FullStory for product analytics?",
|
||||
"answer": "For pure product analytics (funnels, retention, cohorts, user profiles), OpenPanel offers comparable capabilities. FullStory bundles product analytics with its DXI platform, but many teams find they're paying for session replay features they don't heavily use."
|
||||
},
|
||||
{
|
||||
"question": "How does pricing compare?",
|
||||
"answer": "FullStory requires sales conversations with pricing reportedly starting at $12,000/year. OpenPanel offers transparent pricing: 10,000 events/month free, then $0.00005/event. Most teams will save 80-90% with OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host FullStory?",
|
||||
"answer": "No. FullStory is cloud-only SaaS. OpenPanel offers full self-hosting via Docker for complete data control."
|
||||
},
|
||||
{
|
||||
"question": "Is FullStory open source?",
|
||||
"answer": "No. FullStory is proprietary closed-source. OpenPanel is fully open source under the MIT license."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel have autocapture like FullStory?",
|
||||
"answer": "OpenPanel uses explicit event tracking rather than FullStory's autocapture. This means more intentional instrumentation but cleaner data without noise."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "PostHog",
|
||||
"url": "/compare/posthog-alternative"
|
||||
},
|
||||
{
|
||||
"name": "Mixpanel",
|
||||
"url": "/compare/mixpanel-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
494
apps/public/content/compare/funnelio-alternative.json
Normal file
@@ -0,0 +1,494 @@
|
||||
{
|
||||
"slug": "funnelio-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Funnel.io Alternative: Why Teams Choose OpenPanel for Product Analytics",
|
||||
"description": "Compare OpenPanel vs Funnel.io. Understand the key differences between product analytics and marketing data integration, and discover why teams choose OpenPanel for tracking user behavior.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Funnel.io Alternative",
|
||||
"subheading": "Funnel.io aggregates marketing data from ad platforms. OpenPanel tracks user behavior in your product. Different tools for different needs - discover which is right for you.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Product Analytics",
|
||||
"User Behavior Tracking",
|
||||
"Self-hostable"
|
||||
]
|
||||
},
|
||||
"competitor": {
|
||||
"name": "Funnel.io",
|
||||
"logo": "/logos/funnelio.svg",
|
||||
"url": "https://funnel.io",
|
||||
"short_description": "Marketing data integration platform that aggregates data from 500+ ad platforms and marketing tools into unified reports.",
|
||||
"founded": 2014,
|
||||
"headquarters": "Stockholm, Sweden"
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Funnel.io: Which is right for you?",
|
||||
"intro": "Funnel.io and OpenPanel serve fundamentally different purposes. Funnel.io aggregates marketing spend and campaign data from external platforms (Google Ads, Facebook Ads, etc.). OpenPanel tracks user behavior within your product.",
|
||||
"one_liner": "OpenPanel is for product analytics (how users interact with your product); Funnel.io is for marketing data integration (aggregating ad platform data).",
|
||||
"best_for_openpanel": [
|
||||
"Product teams who need to track user behavior in their website or app",
|
||||
"Startups needing affordable analytics with a generous free tier",
|
||||
"Teams requiring self-hosting for data privacy or compliance",
|
||||
"Mobile app developers needing native iOS, Android, React Native SDKs"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Marketing teams aggregating data from multiple ad platforms",
|
||||
"Agencies managing hundreds of marketing data sources for clients",
|
||||
"Organizations needing cross-platform marketing spend reporting",
|
||||
"Teams requiring sophisticated marketing mix modeling"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
"title": "Key differences at a glance",
|
||||
"intro": "Funnel.io and OpenPanel solve different problems. Here's how they compare:",
|
||||
"items": [
|
||||
{
|
||||
"label": "Primary Use Case",
|
||||
"openpanel": "Product analytics - track user behavior",
|
||||
"competitor": "Marketing data aggregation - collect ad data",
|
||||
"notes": "OpenPanel tracks how users interact with your product (clicks, events, conversions). Funnel.io aggregates marketing spend and performance data from ad platforms like Google Ads and Facebook Ads."
|
||||
},
|
||||
{
|
||||
"label": "User Behavior Tracking",
|
||||
"openpanel": "Yes - events, funnels, retention",
|
||||
"competitor": "No - aggregates external platform data",
|
||||
"notes": "OpenPanel provides SDKs to track user behavior on your website or app. Funnel.io does not track user behavior - it imports data from external marketing platforms via API connectors."
|
||||
},
|
||||
{
|
||||
"label": "Self-Hosting & Open Source",
|
||||
"openpanel": "Yes - MIT License, Docker deployment",
|
||||
"competitor": "No - Cloud-only SaaS",
|
||||
"notes": "OpenPanel is fully open source and can be self-hosted. Funnel.io is proprietary cloud-only software with no self-hosting option."
|
||||
},
|
||||
{
|
||||
"label": "Marketing Platform Connectors",
|
||||
"openpanel": "Limited - focused on product data",
|
||||
"competitor": "500+ connectors to ad platforms",
|
||||
"notes": "Funnel.io excels at connecting to 500+ marketing data sources (Google Ads, Facebook Ads, LinkedIn, etc.). OpenPanel focuses on tracking your own product data, not importing external marketing data."
|
||||
},
|
||||
{
|
||||
"label": "Pricing Transparency",
|
||||
"openpanel": "Public pricing, free tier",
|
||||
"competitor": "Starts at $399/month, no free tier",
|
||||
"notes": "OpenPanel offers transparent pricing with a free tier. Funnel.io starts at $399/month (Essentials) with enterprise plans reaching $1,999+/month."
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_comparison": {
|
||||
"title": "Feature comparison",
|
||||
"intro": "These platforms serve different purposes and have different feature sets optimized for their respective use cases.",
|
||||
"groups": [
|
||||
{
|
||||
"group": "User Behavior Tracking",
|
||||
"features": [
|
||||
{
|
||||
"name": "Event Tracking SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io does not track user behavior - it imports data from external platforms"
|
||||
},
|
||||
{
|
||||
"name": "Session Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io aggregates session data from Google Analytics, not direct tracking"
|
||||
},
|
||||
{
|
||||
"name": "User Identification",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel identifies users in your product; Funnel.io works with aggregate marketing data"
|
||||
},
|
||||
{
|
||||
"name": "Custom Event Properties",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel tracks custom events; Funnel.io imports predefined metrics from ad platforms"
|
||||
},
|
||||
{
|
||||
"name": "Real-Time Event Tracking",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io syncs external data on schedules, not real-time user tracking"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Product Analytics",
|
||||
"features": [
|
||||
{
|
||||
"name": "Funnel Analysis (User Behavior)",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel tracks conversion funnels in your product; Funnel.io reports on marketing funnels from ad platforms"
|
||||
},
|
||||
{
|
||||
"name": "Retention Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel tracks user retention over time"
|
||||
},
|
||||
{
|
||||
"name": "Cohort Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel analyzes user cohorts based on behavior"
|
||||
},
|
||||
{
|
||||
"name": "User Profiles",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel creates profiles of individual users"
|
||||
},
|
||||
{
|
||||
"name": "User Path/Journey Analysis",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel tracks user journeys through your product"
|
||||
},
|
||||
{
|
||||
"name": "A/B Testing",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io does not provide experimentation features"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Marketing Data Aggregation",
|
||||
"features": [
|
||||
{
|
||||
"name": "Ad Platform Connectors",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io connects to 500+ marketing platforms (Google Ads, Facebook Ads, etc.)"
|
||||
},
|
||||
{
|
||||
"name": "Marketing Spend Tracking",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io aggregates ad spend across all platforms"
|
||||
},
|
||||
{
|
||||
"name": "Cross-Platform Marketing Reports",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io creates unified reports from multiple ad platforms"
|
||||
},
|
||||
{
|
||||
"name": "Marketing ROI/ROAS Calculation",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io calculates marketing ROI across channels"
|
||||
},
|
||||
{
|
||||
"name": "Marketing Mix Modeling",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io offers advanced MMM for enterprise customers"
|
||||
},
|
||||
{
|
||||
"name": "Data Transformation Rules",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io provides data normalization and transformation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Data Export & Integration",
|
||||
"features": [
|
||||
{
|
||||
"name": "API Access",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Webhooks",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Data Warehouse Export",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both export to BigQuery, Snowflake, etc."
|
||||
},
|
||||
{
|
||||
"name": "Looker Studio Integration",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io has native Looker Studio connector"
|
||||
},
|
||||
{
|
||||
"name": "Power BI Integration",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io has native Power BI connector"
|
||||
},
|
||||
{
|
||||
"name": "Tableau Integration",
|
||||
"openpanel": false,
|
||||
"competitor": true,
|
||||
"notes": "Funnel.io is a Tableau partner"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "SDKs & Platforms",
|
||||
"features": [
|
||||
{
|
||||
"name": "JavaScript SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io does not provide tracking SDKs"
|
||||
},
|
||||
{
|
||||
"name": "iOS SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io does not track mobile apps directly"
|
||||
},
|
||||
{
|
||||
"name": "Android SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io does not track mobile apps directly"
|
||||
},
|
||||
{
|
||||
"name": "React Native SDK",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io does not provide mobile SDKs"
|
||||
},
|
||||
{
|
||||
"name": "Server-Side SDKs",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "OpenPanel supports backend tracking"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Deployment & Hosting",
|
||||
"features": [
|
||||
{
|
||||
"name": "Cloud Hosted",
|
||||
"openpanel": true,
|
||||
"competitor": true
|
||||
},
|
||||
{
|
||||
"name": "Self-Hosted Option",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io is cloud-only with no self-hosting"
|
||||
},
|
||||
{
|
||||
"name": "Open Source",
|
||||
"openpanel": true,
|
||||
"competitor": false,
|
||||
"notes": "Funnel.io is proprietary closed-source"
|
||||
},
|
||||
{
|
||||
"name": "EU Data Residency",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both offer EU hosting options"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"technical_comparison": {
|
||||
"title": "Technical comparison",
|
||||
"intro": "These platforms use fundamentally different technical approaches because they solve different problems.",
|
||||
"items": [
|
||||
{
|
||||
"label": "Deployment",
|
||||
"openpanel": "Docker (self-hosted) or managed cloud",
|
||||
"competitor": "Cloud SaaS only - no self-hosting",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Open Source",
|
||||
"openpanel": "Yes - MIT License",
|
||||
"competitor": "No - Proprietary closed-source",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Database",
|
||||
"openpanel": "ClickHouse",
|
||||
"competitor": "Proprietary (Parquet-based storage)",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Data Retention",
|
||||
"openpanel": "Unlimited (self-hosted), configurable (cloud)",
|
||||
"competitor": "Up to 2-3 years historical data import",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Integrations",
|
||||
"openpanel": "Segment, webhooks, API, data warehouse export",
|
||||
"competitor": "500+ marketing connectors, Looker Studio, Power BI, Tableau, BigQuery, Snowflake, Redshift",
|
||||
"notes": null
|
||||
},
|
||||
{
|
||||
"label": "Primary Data Source",
|
||||
"openpanel": "Your website/app (via SDK tracking)",
|
||||
"competitor": "External marketing platforms (via API connectors)",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Pricing comparison",
|
||||
"intro": "OpenPanel and Funnel.io have very different pricing models reflecting their different use cases.",
|
||||
"openpanel": {
|
||||
"model": "Event-based, transparent",
|
||||
"description": "Simple pricing with 10,000 free events per month. $0.00005 per event after free tier. All features included at every tier. No limits on users, dashboards, or data retention."
|
||||
},
|
||||
"competitor": {
|
||||
"model": "Flexpoint-based, no free tier",
|
||||
"description": "Free plan with limited connectors (100+ Starter connectors). Paid plans: Essentials ($399/month), Plus ($999/month), Enterprise ($1,999+/month). Pricing based on 'flexpoints' which depend on accounts connected and report types. Enterprise customers report spending $12,000-100,000+/year. Agency plans also available.",
|
||||
"free_tier": "Free plan with 100+ Starter connectors and limited features",
|
||||
"pricing_url": "https://funnel.io/pricing"
|
||||
}
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Funnel.io to OpenPanel",
|
||||
"intro": "Funnel.io and OpenPanel serve different purposes. You may need both tools rather than replacing one with the other.",
|
||||
"difficulty": "not-applicable",
|
||||
"estimated_time": "Varies - these tools serve different purposes",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Understand the Difference",
|
||||
"description": "Funnel.io and OpenPanel serve different purposes. Funnel.io aggregates marketing data from ad platforms. OpenPanel tracks user behavior in your product. You may need both tools rather than replacing one with the other."
|
||||
},
|
||||
{
|
||||
"title": "Install OpenPanel SDK",
|
||||
"description": "If you need product analytics (user behavior tracking), add the OpenPanel SDK to your website or app. This gives you event tracking, funnels, retention analysis."
|
||||
},
|
||||
{
|
||||
"title": "Keep Funnel.io for Marketing Data",
|
||||
"description": "If you need cross-platform marketing reporting (aggregating Google Ads, Facebook Ads, etc.), you may want to keep Funnel.io. OpenPanel doesn't replace marketing data aggregation."
|
||||
},
|
||||
{
|
||||
"title": "Connect Data Sources",
|
||||
"description": "OpenPanel tracks your own product data. If you were using Funnel.io to import Google Analytics data, OpenPanel can replace that tracking directly with better product analytics features."
|
||||
},
|
||||
{
|
||||
"title": "Set Up Product Analytics",
|
||||
"description": "Configure funnels, retention analysis, and user identification in OpenPanel. These are product analytics features that Funnel.io does not provide."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
"similar_api": false,
|
||||
"notes": "These tools serve completely different purposes. Funnel.io imports data from external platforms via API connectors. OpenPanel tracks user behavior with client-side SDKs."
|
||||
},
|
||||
"historical_data": {
|
||||
"can_import": false,
|
||||
"notes": "Funnel.io and OpenPanel track different types of data. Funnel.io stores marketing platform data (ad spend, impressions, clicks from external sources). OpenPanel tracks user behavior within your product."
|
||||
}
|
||||
},
|
||||
"use_cases": {
|
||||
"title": "Where OpenPanel is a better fit than Funnel.io",
|
||||
"intro": "Choose OpenPanel when you need to understand user behavior within your product. Funnel.io is for aggregating marketing data from external ad platforms.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Product Teams Needing User Behavior Analytics",
|
||||
"description": "Funnel.io does not track user behavior - it imports data from external platforms. If you need to understand how users interact with your product (clicks, conversions, retention), OpenPanel provides the tracking SDKs and analytics Funnel.io lacks.",
|
||||
"icon": "users"
|
||||
},
|
||||
{
|
||||
"title": "Startups Wanting Affordable Analytics",
|
||||
"description": "Funnel.io starts at $399/month with no free tier. OpenPanel offers a generous free tier (10,000 events/month) and transparent pricing, making it accessible for startups and small teams.",
|
||||
"icon": "dollar-sign"
|
||||
},
|
||||
{
|
||||
"title": "Teams Requiring Self-Hosting",
|
||||
"description": "Funnel.io is cloud-only with no self-hosting option. If you need to host analytics on your own infrastructure for data privacy, compliance, or cost reasons, OpenPanel can be self-hosted via Docker.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"title": "Open Source Requirements",
|
||||
"description": "Funnel.io is proprietary closed-source software. If your organization requires open source tools for security audits or compliance, OpenPanel is fully open source under the MIT license.",
|
||||
"icon": "code"
|
||||
},
|
||||
{
|
||||
"title": "Mobile App Analytics",
|
||||
"description": "Funnel.io does not provide mobile SDKs or track mobile app behavior. OpenPanel offers native iOS, Android, and React Native SDKs for tracking user behavior in mobile apps.",
|
||||
"icon": "smartphone"
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions about the differences between Funnel.io and OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "Is Funnel.io a product analytics tool?",
|
||||
"answer": "No. Funnel.io is a marketing data integration platform that aggregates data from ad platforms (Google Ads, Facebook Ads, etc.) and exports it to BI tools. It does not track user behavior on your website or app. OpenPanel is a product analytics tool that tracks user behavior with SDKs."
|
||||
},
|
||||
{
|
||||
"question": "Can Funnel.io replace OpenPanel?",
|
||||
"answer": "No - they serve different purposes. Funnel.io aggregates marketing spend and campaign data from external platforms. OpenPanel tracks how users interact with your product. Most teams need both: OpenPanel for product analytics and something like Funnel.io for marketing reporting."
|
||||
},
|
||||
{
|
||||
"question": "Does Funnel.io track user events on my website?",
|
||||
"answer": "No. Funnel.io imports data from external platforms like Google Analytics, Google Ads, and Facebook Ads via API connectors. It does not provide tracking SDKs or directly track user behavior. For event tracking, you need a product analytics tool like OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Why is Funnel.io so expensive compared to OpenPanel?",
|
||||
"answer": "Funnel.io's pricing (starting at $399/month) reflects its value proposition: maintaining 500+ API connectors to marketing platforms, handling data transformation, and providing BI tool integrations. OpenPanel tracks your own product data with lightweight SDKs, which has different cost structures."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host Funnel.io?",
|
||||
"answer": "No. Funnel.io is cloud-only SaaS with no self-hosting option. OpenPanel is open source (MIT license) and can be self-hosted via Docker for full data control."
|
||||
},
|
||||
{
|
||||
"question": "Does Funnel.io have mobile SDKs?",
|
||||
"answer": "No. Funnel.io does not provide tracking SDKs for any platform. It imports data from external sources via API connectors. OpenPanel provides native iOS, Android, and React Native SDKs for mobile app analytics."
|
||||
},
|
||||
{
|
||||
"question": "Which tool should I use for conversion funnels?",
|
||||
"answer": "It depends on what you're measuring. OpenPanel tracks user conversion funnels in your product (signup to purchase, onboarding completion). Funnel.io reports on marketing funnel metrics imported from ad platforms. They measure different things."
|
||||
},
|
||||
{
|
||||
"question": "Can OpenPanel replace Funnel.io for marketing reporting?",
|
||||
"answer": "Not directly. OpenPanel doesn't have connectors to import data from Google Ads, Facebook Ads, or other marketing platforms. If you need cross-platform marketing spend reporting, you'll still need a tool like Funnel.io or Supermetrics."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"articles": [
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
"url": "/articles/open-source-web-analytics"
|
||||
}
|
||||
],
|
||||
"alternatives": [
|
||||
{
|
||||
"name": "PostHog",
|
||||
"url": "/compare/posthog-alternative"
|
||||
},
|
||||
{
|
||||
"name": "Mixpanel",
|
||||
"url": "/compare/mixpanel-alternative"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Start with OpenPanel",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||