59 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
12e8c9beaa remove template 2026-01-21 19:50:21 +01:00
Carl-Gerhard Lindesvärd
f9b1ec5038 fix coderabbit comments 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
3fa1a5429e wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
a58761e8d7 wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
56f1c5e894 wip 2026-01-21 15:31:58 +01:00
Carl-Gerhard Lindesvärd
6e997e62f1 docs: add free open source analytics 2026-01-21 11:43:47 +01:00
Carl-Gerhard Lindesvärd
2c5ca8adec fix: validate revenue payload (must be int) 2026-01-21 11:33:15 +01:00
Carl-Gerhard Lindesvärd
3e573ae27f fix: hide billing on self-hosted 2026-01-20 17:41:58 +01:00
Carl-Gerhard Lindesvärd
5b29f7502c wip 2026-01-20 14:39:27 +01:00
Carl-Gerhard Lindesvärd
d32a279949 fix: profileId 2026-01-20 14:08:44 +01:00
Carl-Gerhard Lindesvärd
ed6e5cd334 Revert "fix: test fix broken dashboard #1"
This reverts commit 24ee6b0b6c.

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

* fix: handle past_due and unpaid from polar

* wip

* wip

* wip 1

* fix: improve types for chart/reports

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

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

* fix: build script

* wip

* wip

* wip

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

* rename

* fix: minor things before merging new order keys

* fix: add maintenance mode

* fix: update order by for session and events

* fix: remove properties from sessions and final migration test

* fix: set end date on migrations

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

* remove the trailing newline

* remove the trailing newline
2025-12-15 11:33:58 +01:00
Carl-Gerhard Lindesvärd
0f9ac4508a fix: update react 2025-12-15 11:26:11 +01:00
Carl-Gerhard Lindesvärd
c46cda12eb docs: fix og for tools 2025-12-15 11:16:56 +01:00
Carl-Gerhard Lindesvärd
546ef6673f docs: fix broken build 2025-12-15 11:14:27 +01:00
Carl-Gerhard Lindesvärd
3b2ed3afb1 bump: sdks 2025-12-15 11:01:13 +01:00
Carl-Gerhard Lindesvärd
95846f80e5 docs: fix types 2025-12-15 10:50:40 +01:00
Carl-Gerhard Lindesvärd
1f5c648afe fix: add sdk logs behind debug flag 2025-12-15 10:50:40 +01:00
Carl-Gerhard Lindesvärd
3d8a3e8997 docs: add guides (#258) 2025-12-15 10:19:16 +01:00
341 changed files with 32376 additions and 5003 deletions

View File

@@ -8,6 +8,7 @@ on:
- "apps/api/**"
- "apps/worker/**"
- "apps/public/**"
- "apps/start/**"
- "packages/**"
- "!packages/sdks/**"
- "**Dockerfile"

View File

@@ -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

View File

@@ -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

View File

@@ -8,6 +8,7 @@
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"test:manage": "jiti scripts/test-manage-api.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -52,7 +53,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",

View File

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

View File

@@ -3,15 +3,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
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,
) {

View File

@@ -13,7 +13,7 @@ import {
getSettingsForProject,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { zChartEvent, zReport } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -139,7 +139,7 @@ export async function events(
});
}
const chartSchemeFull = zChartInputBase
const chartSchemeFull = zReport
.pick({
breakdowns: true,
interval: true,

View File

@@ -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),
}),
);
};

View File

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

View File

@@ -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';
@@ -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);

View File

@@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import type {
IncrementProfilePayload,
UpdateProfilePayload,
} from '@openpanel/sdk';
DeprecatedIncrementProfilePayload,
DeprecatedUpdateProfilePayload,
} from '@openpanel/validation';
export async function updateProfile(
request: FastifyRequest<{
Body: UpdateProfilePayload;
Body: DeprecatedUpdateProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -52,7 +52,7 @@ export async function updateProfile(
export async function incrementProfileProperty(
request: FastifyRequest<{
Body: IncrementProfilePayload;
Body: DeprecatedIncrementProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
export async function decrementProfileProperty(
request: FastifyRequest<{
Body: IncrementProfilePayload;
Body: DeprecatedIncrementProfilePayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,19 +1,22 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
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 { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
TrackPayload,
} from '@openpanel/sdk';
import {
type IDecrementPayload,
type IIdentifyPayload,
type IIncrementPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries(
@@ -36,25 +39,28 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
);
}
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
if (body.type === 'track') {
const identity = body.payload.properties?.__identify as
| IIdentifyPayload
| undefined;
return (
identity ||
(body?.payload?.profileId
? {
profileId: body.payload.profileId,
}
: undefined)
);
return (
identity ||
(body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
: undefined)
);
}
return undefined;
}
export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'],
payload: ITrackHandlerPayload['payload'],
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
@@ -81,7 +87,7 @@ export function getTimestamp(
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// isTimestampFromThePast is true only if timestamp is older than 1 hour
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
@@ -91,163 +97,113 @@ export function getTimestamp(
};
}
export async function handler(
request: FastifyRequest<{
Body: TrackHandlerPayload;
}>,
reply: FastifyReply,
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
'properties' in request.body.payload &&
request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'];
const projectId = request.client?.projectId;
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) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
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;
const overrideDeviceId = (() => {
const deviceId =
'properties' in request.body.payload
? request.body.payload.properties?.__deviceId
: undefined;
if (typeof deviceId === 'string') {
return deviceId;
}
return undefined;
})();
// We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload
if (profileId) {
request.body.payload.profileId = profileId;
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 =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const previousDeviceId = 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.previous,
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const promises = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (identity && Object.keys(identity).length > 1) {
promises.push(
identify({
payload: identity,
projectId,
geo,
ua,
}),
);
}
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises);
break;
}
case 'identify': {
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
projectId,
geo,
ua,
});
break;
}
case 'alias': {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
case 'increment': {
await increment({
payload: request.body.payload,
projectId,
});
break;
}
case 'decrement': {
await decrement({
payload: request.body.payload,
projectId,
});
break;
}
default: {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
: '');
previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
}
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: number;
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
@@ -256,44 +212,51 @@ async function track({
: currentDeviceId;
const jobId = [
slug(payload.name),
timestamp,
timestamp.value,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
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,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
groupId,
jobId,
}),
);
await Promise.all(promises);
}
async function identify({
payload,
projectId,
geo,
ua,
}: {
payload: IdentifyPayload;
projectId: string;
geo: GeoLocation;
ua?: string;
}) {
async function handleIdentify(
payload: IIdentifyPayload,
context: TrackContext,
): Promise<void> {
const { projectId, geo, ua } = context;
const uaInfo = parseUserAgent(ua, payload.properties);
await upsertProfile({
...payload,
@@ -318,17 +281,15 @@ async function identify({
});
}
async function increment({
payload,
projectId,
}: {
payload: IncrementPayload;
projectId: string;
}) {
async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload,
projectId: string,
direction: 1 | -1,
): Promise<void> {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new Error('Not found');
throw new HttpError('Profile not found', { status: 404 });
}
const parsed = Number.parseInt(
@@ -337,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,
);
@@ -354,40 +315,74 @@ 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(

View File

@@ -191,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'],
},
},
});

View File

@@ -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,
) {

View File

@@ -1,10 +1,13 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function duplicateHook(
req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload;
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
) {

View File

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

View File

@@ -38,6 +38,7 @@ import exportRouter from './routes/export.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
@@ -143,7 +144,7 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const sessionId = decodeSessionToken(req.cookies.session);
const sessionId = decodeSessionToken(req.cookies?.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
);
@@ -151,6 +152,15 @@ const startServer = async () => {
} catch (e) {
req.session = EMPTY_SESSION;
}
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null),
);
req.session = session;
} catch (e) {
req.session = EMPTY_SESSION;
}
} else {
req.session = EMPTY_SESSION;
}
@@ -194,6 +204,7 @@ const startServer = async () => {
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
instance.register(manageRouter, { prefix: '/manage' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
// New Kubernetes-style health endpoints

View File

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

View File

@@ -14,22 +14,6 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
method: 'POST',
url: '/',
handler: handler,
schema: {
body: {
type: 'object',
required: ['type', 'payload'],
properties: {
type: {
type: 'string',
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
},
payload: {
type: 'object',
additionalProperties: true,
},
},
},
},
});
fastify.route({

View File

@@ -9,7 +9,7 @@ import {
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { zChartInputAI } from '@openpanel/validation';
import { zReportInput } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
@@ -27,7 +27,10 @@ export function getReport({
- ${chartTypes.metric}
- ${chartTypes.bar}
`,
parameters: zChartInputAI,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
execute: async (report) => {
return {
type: 'report',
@@ -72,7 +75,10 @@ export function getConversionReport({
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
parameters: zChartInputAI,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
execute: async (report) => {
return {
type: 'report',
@@ -94,7 +100,10 @@ export function getFunnelReport({
return tool({
description:
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
parameters: zChartInputAI,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
execute: async (report) => {
return {
type: 'report',

View File

@@ -4,10 +4,11 @@ import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
DeprecatedPostEventPayload,
IProjectFilterIp,
IProjectFilterProfileId,
ITrackHandlerPayload,
} from '@openpanel/validation';
import { path } from 'ramda';
@@ -41,7 +42,7 @@ export class SdkAuthError extends Error {
export async function validateSdkRequest(
req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload;
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req;
@@ -235,3 +236,40 @@ export async function validateImportRequest(
return client;
}
export async function validateManageRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
)
) {
throw new Error('Manage: Client ID must be a valid UUIDv4');
}
const client = await getClientByIdCached(clientId);
if (!client) {
throw new Error('Manage: Invalid client id');
}
if (!client.secret) {
throw new Error('Manage: Client has no secret');
}
if (client.type !== ClientType.root) {
throw new Error(
'Manage: Only root clients are allowed to manage resources',
);
}
if (!(await verifyPassword(clientSecret, client.secret))) {
throw new Error('Manage: Invalid client secret');
}
return client;
}

View File

@@ -1,7 +1,13 @@
import urlMetadata from 'url-metadata';
function fallbackFavicon(url: string) {
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
try {
const hostname = new URL(url).hostname;
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
} catch {
// If URL parsing fails, use the original string
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
}
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

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

View File

@@ -3,9 +3,14 @@ title: Astro
---
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Astro analytics guide](/guides/astro-analytics).
</Callout>
## Installation
<Steps>

View File

@@ -4,11 +4,16 @@ description: The Express middleware is a basic wrapper around Javascript SDK. It
---
import Link from 'next/link';
import { Callout } from 'fumadocs-ui/components/callout';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Express analytics guide](/guides/express-analytics).
</Callout>
## Installation
```bash

View File

@@ -13,7 +13,7 @@ For most web projects, we recommend starting with one of these:
- **[Script Tag](/docs/sdks/script)** - The quickest way to get started, no build step required
- **[Web SDK](/docs/sdks/web)** - For TypeScript support and more control
- **[Next.js](/docs/sdks/nextjs)** - Optimized for Next.js applications
- **[Next.js](/docs/sdks/nextjs)** - Optimized for Next.js applications | [Setup guide](/guides/nextjs-analytics)
## Web & Browser SDKs
@@ -38,12 +38,12 @@ For most web projects, we recommend starting with one of these:
### Frontend Frameworks
- **[Next.js](/docs/sdks/nextjs)** - Optimized for Next.js
- **[Next.js](/docs/sdks/nextjs)** - Optimized for Next.js | [Setup guide](/guides/nextjs-analytics)
- ✅ Server and client side tracking
- ✅ App Router support
- ✅ Automatic route tracking
- **[Astro](/docs/sdks/astro)** - Astro framework integration
- **[Astro](/docs/sdks/astro)** - Astro framework integration | [Setup guide](/guides/astro-analytics)
- ✅ Static and SSR support
- ✅ Component-based tracking
- ✅ Island architecture compatible
@@ -51,10 +51,10 @@ For most web projects, we recommend starting with one of these:
- **[React](/docs/sdks/react)** - React integration
- 📝 Use Script Tag or Web SDK for now
- **[Vue](/docs/sdks/vue)** - Vue.js integration
- **[Vue](/docs/sdks/vue)** - Vue.js integration | [Setup guide](/guides/vue-analytics)
- 📝 Use Script Tag or Web SDK for now
- **[Remix](/docs/sdks/remix)** - Remix framework integration
- **[Remix](/docs/sdks/remix)** - Remix framework integration | [Setup guide](/guides/remix-analytics)
- 📝 Use Script Tag or Web SDK for now
- **Svelte** - Svelte integration
@@ -71,12 +71,12 @@ For most web projects, we recommend starting with one of these:
## Server-Side SDKs
- **[Node.js (Express)](/docs/sdks/express)** - Express.js middleware
- **[Node.js (Express)](/docs/sdks/express)** - Express.js middleware | [Setup guide](/guides/express-analytics)
- ✅ Request tracking
- ✅ Error tracking
- ✅ Custom middleware support
- **[Python](/docs/sdks/python)** - Python SDK for server-side tracking
- **[Python](/docs/sdks/python)** - Python SDK for server-side tracking | [Setup guide](/guides/python-analytics)
- ✅ Thread-safe
- ✅ Async support
- ✅ Django and Flask compatible
@@ -93,18 +93,18 @@ For most web projects, we recommend starting with one of these:
## Mobile SDKs
- **[React Native](/docs/sdks/react-native)** - Cross-platform mobile analytics
- **[React Native](/docs/sdks/react-native)** - Cross-platform mobile analytics | [Setup guide](/guides/react-native-analytics)
- ✅ iOS and Android support
- ✅ Native performance
- ✅ Offline support
- **[Swift](/docs/sdks/swift)** - Native iOS, macOS, tvOS, and watchOS SDK
- **[Swift](/docs/sdks/swift)** - Native iOS, macOS, tvOS, and watchOS SDK | [Setup guide](/guides/swift-analytics)
- ✅ Apple platform support
- ✅ Swift Package Manager
- ✅ Thread-safe API
- ✅ Automatic lifecycle tracking
- **[Kotlin / Android](/docs/sdks/kotlin)** - Native Android SDK
- **[Kotlin / Android](/docs/sdks/kotlin)** - Native Android SDK | [Setup guide](/guides/kotlin-analytics)
- ✅ Android support
- ✅ System information collection
- ✅ Thread-safe API

View File

@@ -9,6 +9,10 @@ import CommonSdkConfig from '@/components/common-sdk-config.mdx';
The OpenPanel Kotlin SDK allows you to track user behavior in your Kotlin applications. This guide provides instructions for installing and using the Kotlin SDK in your project.
<Callout>
Looking for a step-by-step tutorial? Check out the [Kotlin analytics guide](/guides/kotlin-analytics).
</Callout>
## Installation
<Callout type="warn">

View File

@@ -7,9 +7,14 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Next.js analytics guide](/guides/nextjs-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!

View File

@@ -0,0 +1,256 @@
---
title: Nuxt
---
import Link from 'next/link';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Nuxt analytics guide](/guides/nuxt-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!
Read more about server side tracking in the [Server Side Tracking](#track-server-events) section.
## Installation
<Steps>
### Install dependencies
```bash
pnpm install @openpanel/nuxt
```
### Initialize
Add the module to your `nuxt.config.ts`:
```typescript
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
},
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
##### Nuxt options
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
- `proxy` - Enable server-side proxy to avoid adblockers (default: `false`)
</Steps>
## Usage
### Using the composable
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component:
```vue
<script setup>
const op = useOpenPanel(); // Auto-imported!
function handleClick() {
op.track('button_click', { button: 'signup' });
}
</script>
<template>
<button @click="handleClick">Trigger event</button>
</template>
```
### Accessing via useNuxtApp
You can also access the OpenPanel instance directly via `useNuxtApp()`:
```vue
<script setup>
const { $openpanel } = useNuxtApp();
$openpanel.track('my_event', { foo: 'bar' });
</script>
```
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```vue
<script setup>
const op = useOpenPanel();
op.track('my_event', { foo: 'bar' });
</script>
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```vue
<script setup>
const op = useOpenPanel();
op.identify({
profileId: '123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
</script>
```
### Setting Global Properties
To set properties that will be sent with every event:
```vue
<script setup>
const op = useOpenPanel();
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
</script>
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```vue
<script setup>
const op = useOpenPanel();
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```vue
<script setup>
const op = useOpenPanel();
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Clearing User Data
To clear the current user's data:
```vue
<script setup>
const op = useOpenPanel();
op.clear();
</script>
```
## Server side
If you want to track server-side events, you should create an instance of our Javascript SDK. Import `OpenPanel` from `@openpanel/sdk`
<Callout>
When using server events it's important that you use a secret to authenticate the request. This is to prevent unauthorized requests since we cannot use cors headers.
You can use the same clientId but you should pass the associated client secret to the SDK.
</Callout>
```typescript
import { OpenPanel } from '@openpanel/sdk';
const opServer = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
});
opServer.track('my_server_event', { ok: '✅' });
// Pass `profileId` to track events for a specific user
opServer.track('my_server_event', { profileId: '123', ok: '✅' });
```
### Serverless & Edge Functions
If you log events in a serverless environment, make sure to await the event call to ensure it completes before the function terminates.
```typescript
import { OpenPanel } from '@openpanel/sdk';
const opServer = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
});
export default defineEventHandler(async (event) => {
// Await to ensure event is logged before function completes
await opServer.track('my_server_event', { foo: 'bar' });
return { message: 'Event logged!' };
});
```
### Proxy events
With the `proxy` option enabled, you can proxy your events through your server, which ensures all events are tracked since many adblockers block requests to third-party domains.
```typescript title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
proxy: true, // Enables proxy at /api/openpanel/*
},
});
```
When `proxy: true` is set:
- The module automatically sets `apiUrl` to `/api/openpanel`
- A server handler is registered at `/api/openpanel/**`
- All tracking requests route through your server
This helps bypass adblockers that might block requests to `api.openpanel.dev`.

View File

@@ -8,6 +8,10 @@ import CommonSdkConfig from '@/components/common-sdk-config.mdx';
The OpenPanel Python SDK allows you to track user behavior in your Python applications. This guide provides instructions for installing and using the Python SDK in your project.
<Callout>
Looking for a step-by-step tutorial? Check out the [Python analytics guide](/guides/python-analytics).
</Callout>
## Installation
<Steps>

View File

@@ -8,8 +8,13 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [React Native analytics guide](/guides/react-native-analytics).
</Callout>
## Installation
<Steps>

View File

@@ -2,4 +2,244 @@
title: React
---
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated react sdk soon.
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
## Good to know
Keep in mind that all tracking here happens on the client!
For React SPAs, you can use `@openpanel/web` directly - no need for a separate React SDK. Simply create an OpenPanel instance and use it throughout your application.
## Installation
<Steps>
### Step 1: Install
```bash
npm install @openpanel/web
```
### Step 2: Initialize
Create a shared OpenPanel instance in your project:
```ts title="src/openpanel.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
### Step 3: Usage
Import and use the instance in your React components:
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleClick = () => {
op.track('button_click', { button: 'signup' });
};
return <button onClick={handleClick}>Trigger event</button>;
}
```
</Steps>
## Usage
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
useEffect(() => {
op.track('my_event', { foo: 'bar' });
}, []);
return <div>My Component</div>;
}
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```tsx
import { op } from '@/openpanel';
function LoginComponent() {
const handleLogin = (user: User) => {
op.identify({
profileId: user.id, // Required
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
tier: 'premium',
},
});
};
return <button onClick={() => handleLogin(user)}>Login</button>;
}
```
### Setting Global Properties
To set properties that will be sent with every event:
```tsx
import { op } from '@/openpanel';
function App() {
useEffect(() => {
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
}, []);
return <div>App</div>;
}
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleAction = () => {
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
};
return <button onClick={handleAction}>Increment</button>;
}
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleAction = () => {
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
};
return <button onClick={handleAction}>Decrement</button>;
}
```
### Clearing User Data
To clear the current user's data:
```tsx
import { op } from '@/openpanel';
function LogoutComponent() {
const handleLogout = () => {
op.clear();
// ... logout logic
};
return <button onClick={handleLogout}>Logout</button>;
}
```
### Revenue Tracking
Track revenue events:
```tsx
import { op } from '@/openpanel';
function CheckoutComponent() {
const handlePurchase = async () => {
// Track revenue immediately
await op.revenue(29.99, { currency: 'USD' });
// Or accumulate revenue and flush later
op.pendingRevenue(29.99, { currency: 'USD' });
op.pendingRevenue(19.99, { currency: 'USD' });
await op.flushRevenue(); // Sends both revenue events
// Clear pending revenue
op.clearRevenue();
};
return <button onClick={handlePurchase}>Purchase</button>;
}
```
### Optional: Create a Hook
If you prefer using a React hook pattern, you can create your own wrapper:
```ts title="src/hooks/useOpenPanel.ts"
import { op } from '@/openpanel';
export function useOpenPanel() {
return op;
}
```
Then use it in your components:
```tsx
import { useOpenPanel } from '@/hooks/useOpenPanel';
function MyComponent() {
const op = useOpenPanel();
useEffect(() => {
op.track('my_event', { foo: 'bar' });
}, []);
return <div>My Component</div>;
}
```

View File

@@ -216,3 +216,4 @@ tracker = OpenPanel::SDK::Tracker.new(
)
```

View File

@@ -9,6 +9,10 @@ import CommonSdkConfig from '@/components/common-sdk-config.mdx';
The OpenPanel Swift SDK allows you to integrate OpenPanel analytics into your iOS, macOS, tvOS, and watchOS applications.
<Callout>
Looking for a step-by-step tutorial? Check out the [Swift analytics guide](/guides/swift-analytics).
</Callout>
## Features
- Easy-to-use API for tracking events and user properties

View File

@@ -2,4 +2,219 @@
title: Vue
---
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated vue sdk soon.
import Link from 'next/link';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Vue analytics guide](/guides/vue-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!
For Vue SPAs, you can use `@openpanel/web` directly - no need for a separate Vue SDK. Simply create an OpenPanel instance and use it throughout your application.
## Installation
<Steps>
### Step 1: Install
```bash
pnpm install @openpanel/web
```
### Step 2: Initialize
Create a shared OpenPanel instance in your project:
```ts title="src/openpanel.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
### Step 3: Usage
Import and use the instance in your Vue components:
```vue
<script setup>
import { op } from '@/openpanel';
function handleClick() {
op.track('button_click', { button: 'signup' });
}
</script>
<template>
<button @click="handleClick">Trigger event</button>
</template>
```
</Steps>
## Usage
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```vue
<script setup>
import { op } from '@/openpanel';
op.track('my_event', { foo: 'bar' });
</script>
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```vue
<script setup>
import { op } from '@/openpanel';
op.identify({
profileId: '123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
</script>
```
### Setting Global Properties
To set properties that will be sent with every event:
```vue
<script setup>
import { op } from '@/openpanel';
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
</script>
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```vue
<script setup>
import { op } from '@/openpanel';
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```vue
<script setup>
import { op } from '@/openpanel';
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Clearing User Data
To clear the current user's data:
```vue
<script setup>
import { op } from '@/openpanel';
op.clear();
</script>
```
### Revenue Tracking
Track revenue events:
```vue
<script setup>
import { op } from '@/openpanel';
// Track revenue immediately
await op.revenue(29.99, { currency: 'USD' });
// Or accumulate revenue and flush later
op.pendingRevenue(29.99, { currency: 'USD' });
op.pendingRevenue(19.99, { currency: 'USD' });
await op.flushRevenue(); // Sends both revenue events
// Clear pending revenue
op.clearRevenue();
</script>
```
### Optional: Create a Composable
If you prefer using a composable pattern, you can create your own wrapper:
```ts title="src/composables/useOpenPanel.ts"
import { op } from '@/openpanel';
export function useOpenPanel() {
return op;
}
```
Then use it in your components:
```vue
<script setup>
import { useOpenPanel } from '@/composables/useOpenPanel';
const op = useOpenPanel();
op.track('my_event', { foo: 'bar' });
</script>
```

View File

@@ -7,11 +7,12 @@ description: Learn how to authenticate with the OpenPanel API using client crede
To authenticate with the OpenPanel API, you need to use your `clientId` and `clientSecret`. Different API endpoints may require different access levels:
- **Track API**: Default client works with `track` mode
- **Track API**: Default client works with `write` mode
- **Export API**: Requires `read` or `root` mode
- **Insights API**: Requires `read` or `root` mode
- **Manage API**: Requires `root` mode only
The default client does not have access to the Export or Insights APIs.
The default client (created with a project) has `write` mode and does not have access to the Export, Insights, or Manage APIs. You'll need to create additional clients with appropriate access levels.
## Headers
@@ -48,15 +49,29 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
Common authentication errors:
- Invalid client ID or secret
- Client doesn't have required permissions
- Malformed client ID
- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client)
- Malformed client ID (must be a valid UUIDv4)
- Client type mismatch (e.g., `write` client trying to access Export API)
## Client Types
OpenPanel supports three client types with different access levels:
| Type | Description | Access |
|------|-------------|--------|
| `write` | Write access | Track API only |
| `read` | Read-only access | Export API, Insights API |
| `root` | Full access | All APIs including Manage API |
**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely.
## Rate Limiting
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
- **Track API**: Higher limits for event tracking
- **Export/Insights APIs**: Lower limits for data retrieval
- **Export/Insights APIs**: 100 requests per 10 seconds
- **Manage API**: 20 requests per 10 seconds
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.

View File

@@ -0,0 +1,332 @@
---
title: Clients
description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels.
---
## Authentication
To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Clients API requests should be made to:
```
https://api.openpanel.dev/manage/clients
```
## Client Types
OpenPanel supports three client types with different access levels:
| Type | Description | Use Case |
|------|-------------|----------|
| `read` | Read-only access | Export data, view insights, read-only operations |
| `write` | Write access | Track events, send data to OpenPanel |
| `root` | Full access | Manage resources, access Manage API |
**Note**: Only `root` clients can access the Manage API.
## Endpoints
### List Clients
Retrieve all clients in your organization, optionally filtered by project.
```
GET /manage/clients
```
#### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `projectId` | string | Optional. Filter clients by project ID |
#### Example Request
```bash
# List all clients
curl 'https://api.openpanel.dev/manage/clients' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
# List clients for a specific project
curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"name": "First client",
"type": "write",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
},
{
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "Read-only Client",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z"
}
]
}
```
**Note**: Client secrets are never returned in list or get responses for security reasons.
### Get Client
Retrieve a specific client by ID.
```
GET /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"name": "First client",
"type": "write",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
}
```
### Create Client
Create a new API client. A secure secret is automatically generated and returned once.
```
POST /manage/clients
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Client name (minimum 1 character) |
| `projectId` | string | No | Associate client with a specific project |
| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) |
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/clients' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "My API Client",
"projectId": "my-project",
"type": "read"
}'
```
#### Response
```json
{
"data": {
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "My API Client",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"secret": "sec_b2521ca283bf903b46b3"
}
}
```
**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client.
### Update Client
Update an existing client's name.
```
PATCH /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | No | New client name (minimum 1 character) |
**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client.
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "Updated Client Name"
}'
```
#### Response
```json
{
"data": {
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "Updated Client Name",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:30:00.000Z"
}
}
```
### Delete Client
Permanently delete a client. This action cannot be undone.
```
DELETE /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client.
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Client not found"
}
```
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The Clients API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Security Best Practices
1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems
2. **Use Appropriate Client Types**: Use the minimum required access level for each use case
3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets
4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code
5. **Monitor Client Usage**: Regularly review and remove unused clients
## Notes
- Client IDs are UUIDs (Universally Unique Identifiers)
- Client secrets are automatically generated with the format `sec_` followed by random hex characters
- Secrets are hashed using argon2 before storage
- Clients can be associated with a project or exist at the organization level
- Clients are scoped to your organization - you can only manage clients in your organization
- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution)

View File

@@ -0,0 +1,140 @@
---
title: Manage API Overview
description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API.
---
## Overview
The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks.
## Authentication
The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization.
To authenticate with the Manage API, you need:
- A client with `type: 'root'`
- Your `clientId` and `clientSecret`
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Manage API requests should be made to:
```
https://api.openpanel.dev/manage
```
## Available Resources
The Manage API provides CRUD operations for three resource types:
### Projects
Manage your analytics projects programmatically:
- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects
- Automatically creates a default write client when creating a project
- Supports project configuration including domains, CORS settings, and project types
### Clients
Manage API clients for your projects:
- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients
- Supports different client types: `read`, `write`, and `root`
- Auto-generates secure secrets on creation (returned once)
### References
Manage reference points for your analytics:
- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references
- Useful for marking important dates or events in your analytics timeline
- Can be filtered by project
## Common Features
All endpoints share these common characteristics:
### Organization Scope
All operations are scoped to your organization. You can only manage resources that belong to your organization.
### Response Format
Successful responses follow this structure:
```json
{
"data": {
// Resource data
}
}
```
For list endpoints:
```json
{
"data": [
// Array of resources
]
}
```
### Error Handling
The API uses standard HTTP response codes:
- `200 OK` - Request successful
- `400 Bad Request` - Invalid request parameters
- `401 Unauthorized` - Authentication failed
- `404 Not Found` - Resource not found
- `429 Too Many Requests` - Rate limit exceeded
## Rate Limiting
The Manage API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Use Cases
The Manage API is ideal for:
- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure
- **Automation**: Automatically create projects and clients for new deployments
- **Bulk Operations**: Programmatically manage multiple resources
- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline
- **Administrative Tools**: Build custom admin interfaces
## Security Best Practices
1. **Root Clients Only**: Only root clients can access the Manage API
2. **Store Credentials Securely**: Never expose root client credentials in client-side code
3. **Use HTTPS**: Always use HTTPS for API requests
4. **Rotate Credentials**: Regularly rotate your root client credentials
5. **Limit Access**: Restrict root client creation to trusted administrators
## Getting Started
1. **Create a Root Client**: Use the dashboard to create a root client in your organization
2. **Store Credentials**: Securely store your root client ID and secret
3. **Make Your First Request**: Start with listing projects to verify authentication
Example:
```bash
curl 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
## Next Steps
- Read the [Projects documentation](/docs/api/manage/projects) to manage projects
- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients
- Read the [References documentation](/docs/api/manage/references) to manage reference points

View File

@@ -0,0 +1,5 @@
{
"title": "Manage",
"pages": ["projects", "clients", "references"],
"defaultOpen": false
}

View File

@@ -0,0 +1,327 @@
---
title: Projects
description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API.
---
## Authentication
To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Projects API requests should be made to:
```
https://api.openpanel.dev/manage/projects
```
## Endpoints
### List Projects
Retrieve all projects in your organization.
```
GET /manage/projects
```
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "my-project",
"name": "My Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null
}
]
}
```
### Get Project
Retrieve a specific project by ID.
```
GET /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "my-project",
"name": "My Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null
}
}
```
### Create Project
Create a new project in your organization. A default write client is automatically created with the project.
```
POST /manage/projects
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Project name (minimum 1 character) |
| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) |
| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) |
| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) |
| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) |
#### Project Types
- `website`: Web-based project
- `app`: Mobile application
- `backend`: Backend/server-side project
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "My New Project",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"types": ["website"]
}'
```
#### Response
```json
{
"data": {
"id": "my-new-project",
"name": "My New Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null,
"client": {
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"secret": "sec_6c8ae85a092d6c66b242"
}
}
}
```
**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately.
### Update Project
Update an existing project's configuration.
```
PATCH /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Request Body
All fields are optional. Only include fields you want to update.
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Project name (minimum 1 character) |
| `domain` | string \| null | Primary domain (URL format, empty string, or null) |
| `cors` | string[] | Array of allowed CORS origins |
| `crossDomain` | boolean | Enable cross-domain tracking |
| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret |
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "Updated Project Name",
"crossDomain": true,
"allowUnsafeRevenueTracking": false
}'
```
#### Response
```json
{
"data": {
"id": "my-project",
"name": "Updated Project Name",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com"],
"crossDomain": true,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"deleteAt": null
}
}
```
### Delete Project
Soft delete a project. The project will be scheduled for deletion after 24 hours.
```
DELETE /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time.
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Project not found"
}
```
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The Projects API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Notes
- Project IDs are automatically generated from the project name using a slug format
- If a project ID already exists, a numeric suffix is added
- CORS domains are automatically normalized (trailing slashes removed)
- The default client created with a project has `type: 'write'`
- Projects are scoped to your organization - you can only manage projects in your organization
- Soft-deleted projects are excluded from list endpoints

View File

@@ -0,0 +1,344 @@
---
title: References
description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline.
---
## Authentication
To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All References API requests should be made to:
```
https://api.openpanel.dev/manage/references
```
## What are References?
References are markers you can add to your analytics timeline to track important events such as:
- Product launches
- Marketing campaign start dates
- Feature releases
- Website redesigns
- Major announcements
References appear in your analytics charts and help you correlate changes in metrics with specific events.
## Endpoints
### List References
Retrieve all references in your organization, optionally filtered by project.
```
GET /manage/references
```
#### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `projectId` | string | Optional. Filter references by project ID |
#### Example Request
```bash
# List all references
curl 'https://api.openpanel.dev/manage/references' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
# List references for a specific project
curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
},
{
"id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96",
"title": "Marketing Campaign Start",
"description": "Q1 2024 campaign launched",
"date": "2024-01-20T09:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-18T10:00:00.000Z",
"updatedAt": "2024-01-18T10:00:00.000Z"
}
]
}
```
### Get Reference
Retrieve a specific reference by ID.
```
GET /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
}
}
```
### Create Reference
Create a new reference point for a project.
```
POST /manage/references
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `projectId` | string | Yes | The ID of the project this reference belongs to |
| `title` | string | Yes | Reference title (minimum 1 character) |
| `description` | string | No | Optional description or notes |
| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) |
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/references' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"projectId": "my-project",
"title": "Product Launch",
"description": "Version 2.0 released with new features",
"datetime": "2024-01-15T10:00:00.000Z"
}'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released with new features",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
}
}
```
**Note**: The `date` field in the response is parsed from the `datetime` string you provided.
### Update Reference
Update an existing reference.
```
PATCH /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Request Body
All fields are optional. Only include fields you want to update.
| Parameter | Type | Description |
|-----------|------|-------------|
| `title` | string | Reference title (minimum 1 character) |
| `description` | string \| null | Description or notes (set to `null` to clear) |
| `datetime` | string | Date and time for the reference (ISO 8601 format) |
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"title": "Product Launch v2.1",
"description": "Updated: Version 2.1 released with bug fixes",
"datetime": "2024-01-15T10:00:00.000Z"
}'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch v2.1",
"description": "Updated: Version 2.1 released with bug fixes",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T09:30:00.000Z"
}
}
```
### Delete Reference
Permanently delete a reference. This action cannot be undone.
```
DELETE /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["title"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Reference not found"
}
```
This error can occur if:
- The reference ID doesn't exist
- The reference belongs to a different organization
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The References API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Date Format
References use ISO 8601 date format. Examples:
- `2024-01-15T10:00:00.000Z` - UTC timezone
- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5)
- `2024-01-15` - Date only (time defaults to 00:00:00)
The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp.
## Use Cases
References are useful for:
- **Product Launches**: Mark when new versions or features are released
- **Marketing Campaigns**: Track campaign start and end dates
- **Website Changes**: Note when major redesigns or updates occur
- **Business Events**: Record important business milestones
- **A/B Testing**: Mark when experiments start or end
- **Seasonal Events**: Track holidays, sales periods, or seasonal changes
## Notes
- Reference IDs are UUIDs (Universally Unique Identifiers)
- References are scoped to projects - each reference belongs to a specific project
- References are scoped to your organization - you can only manage references for projects in your organization
- The `description` field is optional and can be set to `null` to clear it
- References appear in analytics charts to help correlate metrics with events
- When filtering by `projectId`, the project must exist and belong to your organization

View File

@@ -1,4 +1,4 @@
{
"title": "API",
"pages": ["track", "export", "insights"]
"pages": ["track", "export", "insights", "manage"]
}

View File

@@ -0,0 +1,101 @@
---
title: Migration from v1 to v2
description: Finally we feel ready to release v2 for all self-hostings. This is a big one!
---
## What's New in v2
- **Redesigned dashboard** - New UI built with Tanstack
- **Revenue tracking** - Track revenue alongside your analytics
- **Sessions** - View individual user sessions
- **Real-time view** - Live event stream
- **Customizable dashboards** - Grafana-style widget layouts
- **Improved report builder** - Faster and more flexible
- **General improvements** - We have also made a bunch of bug fixes, minor improvements and much more
## Migrating from v1
### Ensure you're on the self-hosting branch
Sometimes we add new helper scripts and what not. Always make sure you're on the latest commit before continuing.
```bash
cd ./self-hosting
git fetch origin
git checkout self-hosting
git pull origin self-hosting
```
### Envs
Since we have migrated to tanstack from nextjs we first need to update our envs. We have added a dedicated page for the [environment variables here](/docs/self-hosting/environment-variables).
```js title=".env"
NEXT_PUBLIC_DASHBOARD_URL="..." // [!code --]
NEXT_PUBLIC_API_URL="..." // [!code --]
NEXT_PUBLIC_SELF_HOSTED="..." // [!code --]
DASHBOARD_URL="..." // [!code ++]
API_URL="..." // [!code ++]
SELF_HOSTED="..." // [!code ++]
```
### Clickhouse 24 -> 25
We have updated Clickhouse to 25, this is important to not skip, otherwise your OpenPanel instance wont work.
You should edit your `./self-hosting/docker-compose.yml`
```js title="./self-hosting/docker-compose.yml"
services:
op-ch:
image: clickhouse/clickhouse-server:24.3.2-alpine // [!code --]
image: clickhouse/clickhouse-server:25.10.2.65 // [!code ++]
```
Since version 25 clickhouse enabled default user setup, this means that we need to disable it to avoid connection issues. With this setting we can still access our clickhouse instance (internally) without having a user.
```
services:
op-ch:
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
```
### Use our latest docker images
Last thing to do is to start using our latest docker images.
> Note: Before you might have been using the latest tag, which is not recommended. Change it to the actual latest version instead.
```js title="./self-hosting/docker-compose.yml"
services:
op-api:
image: lindesvard/openpanel-api:latest // [!code --]
image: lindesvard/openpanel-api:2.0.0 // [!code ++]
op-worker:
image: lindesvard/openpanel-worker:latest // [!code --]
image: lindesvard/openpanel-worker:2.0.0 // [!code ++]
op-dashboard:
image: lindesvard/openpanel-dashboard:latest // [!code --]
image: lindesvard/openpanel-dashboard:2.0.0 // [!code ++]
```
### Done?
When you're done with above steps you should need to restart all services. This will take quite some time depending on your hardware and how many events you have. Since we have made significant changes to the database schema and data we need to run migrations.
```bash
./stop
./start
```
## Using Coolify?
If you're using Coolify and running OpenPanel v1 you'll need to apply the above changes. You can take a look at our [Coolify PR](https://github.com/coollabsio/coolify/pull/7653) which shows what you need to change.
## Any issues with migrations?
If you stumble upon any issues during migrations, please reach out to us on [Discord](https://discord.gg/openpanel) and we'll try our best to help you out.

View File

@@ -3,6 +3,20 @@ title: Changelog for self-hosting
description: This is a list of changes that have been made to the self-hosting setup.
---
## 2.0.0
We have released the first stable version of OpenPanel v2. This is a big one!
Read more about it in our [migration guide](/docs/migration/migrate-v1-to-v2).
TLDR;
- Clickhouse upgraded from 24.3.2-alpine to 25.10.2.65
- Add `CLICKHOUSE_SKIP_USER_SETUP=1` to op-ch service
- `NEXT_PUBLIC_DASHBOARD_URL` -> `DASHBOARD_URL`
- `NEXT_PUBLIC_API_URL` -> `API_URL`
- `NEXT_PUBLIC_SELF_HOSTED` -> `SELF_HOSTED`
## 1.2.0
We have renamed `SELF_HOSTED` to `NEXT_PUBLIC_SELF_HOSTED`. It's important to rename this env before your upgrade to this version.
@@ -30,7 +44,7 @@ If you upgrading from a previous version, you'll need to edit your `.env` file i
### Removed Clickhouse Keeper
In 0.0.6 we introduced a cluster mode for Clickhouse. This was a misstake and we have removed it.
In 0.0.6 we introduced a cluster mode for Clickhouse. This was a mistake and we have removed it.
Remove op-zk from services and volumes

View File

@@ -109,8 +109,8 @@ Coolify automatically handles these variables:
- `DATABASE_URL`: PostgreSQL connection string
- `REDIS_URL`: Redis connection string
- `CLICKHOUSE_URL`: ClickHouse connection string
- `NEXT_PUBLIC_API_URL`: API endpoint URL (set via `SERVICE_FQDN_OPAPI`)
- `NEXT_PUBLIC_DASHBOARD_URL`: Dashboard URL (set via `SERVICE_FQDN_OPDASHBOARD`)
- `API_URL`: API endpoint URL (set via `SERVICE_FQDN_OPAPI`)
- `DASHBOARD_URL`: Dashboard URL (set via `SERVICE_FQDN_OPDASHBOARD`)
- `COOKIE_SECRET`: Automatically generated secret
You can configure optional variables like `ALLOW_REGISTRATION`, `RESEND_API_KEY`, `OPENAI_API_KEY`, etc. through Coolify's environment variable interface.

View File

@@ -126,7 +126,7 @@ If you want to use specific image versions, edit the `docker-compose.yml` file a
```yaml
op-api:
image: lindesvard/openpanel-api:v1.0.0 # Specify version
image: lindesvard/openpanel-api:2.0.0 # Specify version
```
### Scaling Workers

View File

@@ -54,8 +54,8 @@ Edit the `.env` file or environment variables in Dokploy. You **must** set these
```bash
# Required: Set these to your actual domain
NEXT_PUBLIC_API_URL=https://yourdomain.com/api
NEXT_PUBLIC_DASHBOARD_URL=https://yourdomain.com
API_URL=https://yourdomain.com/api
DASHBOARD_URL=https://yourdomain.com
# Database Configuration (automatically set by Dokploy)
OPENPANEL_POSTGRES_DB=openpanel-db
@@ -71,7 +71,7 @@ OPENPANEL_EMAIL_SENDER=noreply@yourdomain.com
```
<Callout type="warn">
⚠️ **Critical**: Unlike Coolify, Dokploy does not support `SERVICE_FQDN_*` variables. You **must** hardcode `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_DASHBOARD_URL` with your actual domain values.
⚠️ **Critical**: Unlike Coolify, Dokploy does not support `SERVICE_FQDN_*` variables. You **must** hardcode `API_URL` and `DASHBOARD_URL` with your actual domain values.
</Callout>
</Step>
@@ -133,8 +133,8 @@ If you're using Cloudflare in front of Dokploy, remember to purge the Cloudflare
For Dokploy, you **must** hardcode these variables (unlike Coolify, Dokploy doesn't support `SERVICE_FQDN_*` variables):
- `NEXT_PUBLIC_API_URL` - Full API URL (e.g., `https://analytics.example.com/api`)
- `NEXT_PUBLIC_DASHBOARD_URL` - Full Dashboard URL (e.g., `https://analytics.example.com`)
- `API_URL` - Full API URL (e.g., `https://analytics.example.com/api`)
- `DASHBOARD_URL` - Full Dashboard URL (e.g., `https://analytics.example.com`)
Dokploy automatically sets:
- `OPENPANEL_POSTGRES_DB` - PostgreSQL database name
@@ -166,9 +166,9 @@ If API requests fail after deployment:
1. **Verify environment variables**:
```bash
# Check that NEXT_PUBLIC_API_URL is set correctly
docker exec <op-api-container> env | grep NEXT_PUBLIC_API_URL
docker exec <op-dashboard-container> env | grep NEXT_PUBLIC_API_URL
# Check that API_URL is set correctly
docker exec <op-api-container> env | grep API_URL
docker exec <op-dashboard-container> env | grep API_URL
```
2. **Check "Strip external path" setting**:
@@ -188,7 +188,7 @@ If account creation fails:
# In Dokploy, view logs for op-api service
```
2. Verify `NEXT_PUBLIC_API_URL` matches your domain:
2. Verify `API_URL` matches your domain:
- Should be `https://yourdomain.com/api`
- Not `http://localhost:3000` or similar
@@ -240,7 +240,7 @@ The Dokploy template differs from Coolify in these ways:
1. **Environment Variables**:
- Dokploy does not support `SERVICE_FQDN_*` variables
- Must hardcode `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_DASHBOARD_URL`
- Must hardcode `API_URL` and `DASHBOARD_URL`
2. **Domain Configuration**:
- Must manually configure domain paths

View File

@@ -116,7 +116,7 @@ Remove `convert_any_join` from ClickHouse settings. Used for compatibility with
## Application URLs
### NEXT_PUBLIC_API_URL
### API_URL
**Type**: `string`
**Required**: Yes
@@ -126,10 +126,10 @@ Public API URL exposed to the browser. Used by the dashboard frontend and API se
**Example**:
```bash
NEXT_PUBLIC_API_URL=https://analytics.example.com/api
API_URL=https://analytics.example.com/api
```
### NEXT_PUBLIC_DASHBOARD_URL
### DASHBOARD_URL
**Type**: `string`
**Required**: Yes
@@ -139,7 +139,7 @@ Public dashboard URL exposed to the browser. Used by the dashboard frontend and
**Example**:
```bash
NEXT_PUBLIC_DASHBOARD_URL=https://analytics.example.com
DASHBOARD_URL=https://analytics.example.com
```
### API_CORS_ORIGINS
@@ -175,6 +175,48 @@ COOKIE_SECRET=your-random-secret-here
Never use the default value in production! Always generate a unique secret.
</Callout>
### COOKIE_TLDS
**Type**: `string` (comma-separated)
**Required**: No
**Default**: None
Custom multi-part TLDs for cookie domain handling. Use this when deploying on domains with public suffixes that aren't recognized by default (e.g., `.my.id`, `.web.id`, `.co.id`).
**Example**:
```bash
# For domains like abc.my.id
COOKIE_TLDS=my.id
# Multiple TLDs
COOKIE_TLDS=my.id,web.id,co.id
```
<Callout>
This is required when using domain suffixes that are public suffixes (like `.co.uk`). Without this, the browser will reject authentication cookies. Common examples include Indonesian domains (`.my.id`, `.web.id`, `.co.id`).
</Callout>
### CUSTOM_COOKIE_DOMAIN
**Type**: `string`
**Required**: No
**Default**: None
Override the automatic cookie domain detection and set a specific domain for authentication cookies. Useful when proxying the API through your main domain or when you need precise control over cookie scope.
**Example**:
```bash
# Set cookies only on the main domain
CUSTOM_COOKIE_DOMAIN=.example.com
# Set cookies on a specific subdomain
CUSTOM_COOKIE_DOMAIN=.app.example.com
```
<Callout>
When set, this completely bypasses the automatic domain parsing logic. The cookie will always be set as secure. Include a leading dot (`.`) to allow the cookie to be shared across subdomains.
</Callout>
### DEMO_USER_ID
**Type**: `string`
@@ -368,7 +410,7 @@ SLACK_STATE_SECRET=your-state-secret
## Self-hosting
### NEXT_PUBLIC_SELF_HOSTED
### SELF_HOSTED
**Type**: `boolean`
**Required**: No
@@ -378,7 +420,7 @@ Enable self-hosted mode. Set to `true` or `1` to enable self-hosting features. U
**Example**:
```bash
NEXT_PUBLIC_SELF_HOSTED=true
SELF_HOSTED=true
```
## Worker & Queue
@@ -784,8 +826,8 @@ For a basic self-hosted installation, these variables are required:
- `DATABASE_URL` - PostgreSQL connection
- `REDIS_URL` - Redis connection
- `CLICKHOUSE_URL` - ClickHouse connection
- `NEXT_PUBLIC_API_URL` - API endpoint URL
- `NEXT_PUBLIC_DASHBOARD_URL` - Dashboard URL
- `API_URL` - API endpoint URL
- `DASHBOARD_URL` - Dashboard URL
- `COOKIE_SECRET` - Session encryption secret
### Optional but Recommended

View File

@@ -163,7 +163,7 @@ For complete AI configuration details, see the [Environment Variables documentat
If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually.
Without this setting we wont be able to listen for expired keys which we use for caluclating currently active vistors.
Without this setting we won't be able to listen for expired keys which we use for calculating currently active visitors.
> You will see a warning in the logs if this needs to be set manually.

View File

@@ -0,0 +1,171 @@
---
title: "How to add analytics to Astro"
description: "Add privacy-first analytics to your Astro site with OpenPanel. Track page views, custom events, and user behavior without cookies."
difficulty: beginner
timeToComplete: 5
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Add the component to your layout"
anchor: "setup"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Astro
Adding analytics to your Astro site helps you understand how visitors interact with your content. This guide walks you through setting up OpenPanel to track page views, custom events, and user behavior in about five minutes.
OpenPanel works well with Astro because it's a lightweight script that loads asynchronously and doesn't block your site's rendering. It tracks page views automatically across both static and server-rendered pages, and the component-based API fits naturally into Astro's architecture.
## Prerequisites
- An Astro project
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the SDK [#install]
Start by adding the OpenPanel Astro package to your project. This package provides Astro components that handle initialization and tracking.
```bash
npm install @openpanel/astro
```
You can also use pnpm or yarn if that's your preference.
## Add the component to your layout [#setup]
The `OpenPanelComponent` initializes tracking and should be placed in your root layout so it loads on every page. Add it inside the `<head>` tag to ensure it initializes before any user interactions.
```astro
---
import { OpenPanelComponent } from '@openpanel/astro';
---
<html>
<head>
<OpenPanelComponent
clientId="your-client-id"
trackScreenViews={true}
trackOutgoingLinks={true}
/>
</head>
<body>
<slot />
</body>
</html>
```
The `trackScreenViews` option automatically records a page view event whenever someone navigates to a new page. The `trackOutgoingLinks` option tracks when visitors click links that take them to external sites. Both are optional but recommended for most sites.
You can also pass a `profileId` prop if you already know the user's identity at render time, and `globalProperties` to attach metadata to every event.
## Track custom events [#events]
Beyond automatic page views, you'll want to track specific interactions that matter to your business. OpenPanel exposes a global `op` function that you can call from any event handler.
```astro
<button onclick="window.op('track', 'button_clicked', {button_name: 'signup'})">
Sign up
</button>
```
The first argument is always `'track'`, the second is your event name, and the third is an optional object of properties you want to attach to the event. Keep event names consistent across your codebase, using snake_case is a good convention.
For elements where you'd rather not write JavaScript, you can use data attributes instead. Any element with a `data-track` attribute will automatically fire an event when clicked.
```astro
<button data-track="button_clicked" data-button-name="signup">
Sign up
</button>
```
Properties are pulled from any `data-*` attributes on the element. The `data-track` value becomes the event name, and other data attributes become event properties.
### Tracking form submissions
Forms are a common tracking target. You can fire an event in the `onsubmit` handler while still allowing the form to submit normally.
```astro
<form onsubmit="window.op('track', 'form_submitted', {form_name: 'contact'}); return true;">
<input type="email" name="email" placeholder="Your email" required />
<button type="submit">Submit</button>
</form>
```
The `return true` ensures the form submission continues after the tracking call.
## Identify users [#identify]
When a user logs in or you otherwise learn their identity, you can associate their activity with a profile. The `IdentifyComponent` handles this declaratively.
```astro
---
import { IdentifyComponent } from '@openpanel/astro';
const user = await getCurrentUser();
---
<IdentifyComponent
profileId={user.id}
firstName={user.firstName}
lastName={user.lastName}
email={user.email}
properties={{
plan: user.plan,
}}
/>
```
Place this component on pages where the user is authenticated. Once identified, all subsequent events from that browser session will be linked to this profile until they clear their browser data or you explicitly clear the identity.
### Setting global properties
Sometimes you want to attach the same properties to every event, like an app version or environment. The `SetGlobalPropertiesComponent` lets you do this once rather than repeating it in every tracking call.
```astro
---
import { SetGlobalPropertiesComponent } from '@openpanel/astro';
---
<SetGlobalPropertiesComponent
properties={{
app_version: '1.0.2',
environment: import.meta.env.MODE,
}}
/>
```
These properties merge with any event-specific properties you pass to individual tracking calls.
## Verify your setup [#verify]
Open your Astro site in the browser and navigate between a few pages. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view. You should see page view events appearing within seconds.
If events aren't showing up, open your browser's developer console and look for errors. The most common issues are an incorrect Client ID or an ad blocker preventing the tracking script from loading. You can also check the Network tab to confirm requests are being sent to OpenPanel's servers.
## Next steps
The [Astro SDK reference](/docs/sdks/astro) covers additional configuration options like filtering events and customizing the CDN URL. If you're interested in running OpenPanel on your own infrastructure, the [self-hosting guide](/articles/how-to-self-host-openpanel) walks through the setup process.
<Faqs>
<FaqItem question="Does OpenPanel work with Astro Islands?">
Yes. The OpenPanelComponent is a client-side script that hydrates independently of your island components. It will track interactions across your entire page regardless of which parts are hydrated.
</FaqItem>
<FaqItem question="Can I use OpenPanel with Astro's SSR mode?">
Yes. OpenPanel works with both static and server-rendered Astro sites. The tracking script runs in the browser regardless of how the page was rendered.
</FaqItem>
<FaqItem question="Does OpenPanel use cookies?">
No. OpenPanel uses cookieless tracking by default, which means you don't need cookie consent banners for basic analytics under most privacy regulations including GDPR. Read more about [cookieless analytics](/articles/cookieless-analytics).
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,200 @@
---
title: "How to track e-commerce events and revenue"
description: "Track product views, cart activity, and purchases with OpenPanel to understand your e-commerce funnel and revenue."
difficulty: intermediate
timeToComplete: 10
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Track product views"
anchor: "track-product-views"
- name: "Track cart activity"
anchor: "track-cart-activity"
- name: "Track purchases and revenue"
anchor: "track-purchases-and-revenue"
- name: "Verify your setup"
anchor: "verify"
---
# How to track e-commerce events and revenue
E-commerce tracking gives you visibility into how users interact with your products and what drives purchases. By the end of this guide, you'll have product views, cart events, and revenue tracking working in your store.
OpenPanel tracks revenue using a dedicated `revenue()` method that links payments to visitor sessions. This lets you see which traffic sources, campaigns, and pages generate the most revenue. You can track from your frontend for quick setup, or from your backend via webhooks for more accurate data.
## Prerequisites
- An OpenPanel account
- Your Client ID from the [dashboard](https://dashboard.openpanel.dev)
- The OpenPanel SDK installed in your project
## Track product views [#track-product-views]
Product view tracking helps you understand which items attract attention. When a user lands on a product page, fire an event with the product details so you can later analyze which products get viewed but not purchased.
```tsx
function ProductPage({ product }) {
const op = useOpenPanel();
useEffect(() => {
op.track('product_viewed', {
product_id: product.id,
product_name: product.name,
product_category: product.category,
product_price: product.price,
currency: 'USD',
});
}, [product.id]);
return <div>{product.name}</div>;
}
```
Include consistent properties across all your product events. Using the same `product_id` format everywhere makes it easy to build reports that connect views to purchases.
## Track cart activity [#track-cart-activity]
Cart events reveal where users drop off in the purchase process. Track both additions and removals to understand cart abandonment patterns.
When a user adds an item, capture the product details along with the current cart value. This gives you context about order sizes at different stages of the funnel.
```tsx
function ProductCard({ product, cart }) {
const op = useOpenPanel();
const handleAddToCart = () => {
op.track('product_added_to_cart', {
product_id: product.id,
product_name: product.name,
product_price: product.price,
quantity: 1,
cart_value: cart.total + product.price,
currency: 'USD',
});
addToCart(product);
};
return (
<button onClick={handleAddToCart}>Add to Cart</button>
);
}
```
Track removals the same way. The symmetry between add and remove events makes it straightforward to calculate net cart changes.
```tsx
const handleRemoveFromCart = (item) => {
op.track('product_removed_from_cart', {
product_id: item.id,
product_name: item.name,
product_price: item.price,
quantity: item.quantity,
currency: 'USD',
});
removeFromCart(item);
};
```
## Track purchases and revenue [#track-purchases-and-revenue]
Revenue tracking is where e-commerce analytics becomes actionable. OpenPanel provides dedicated methods for revenue that link payments back to visitor sessions, so you can see which traffic sources generate the most value.
For frontend tracking, use `pendingRevenue()` before redirecting to checkout, then `flushRevenue()` on your success page. This approach works well when you want to get started quickly without backend changes.
```tsx
async function handleCheckout(cart) {
const op = useOpenPanel();
op.pendingRevenue(cart.total, {
order_items: cart.items.length,
currency: 'USD',
});
window.location.href = await createCheckoutUrl(cart);
}
```
On your success page, flush the pending revenue to send it to OpenPanel.
```tsx
function SuccessPage() {
const op = useOpenPanel();
useEffect(() => {
op.flushRevenue();
}, []);
return <div>Thank you for your purchase!</div>;
}
```
For more accurate tracking, handle revenue in your backend webhook. This ensures you only record completed payments. Pass the visitor's `deviceId` when creating the checkout so you can link the payment back to their session.
```tsx
// Frontend: include deviceId when starting checkout
const deviceId = await op.fetchDeviceId();
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({
deviceId,
items: cart.items,
}),
});
```
In your webhook handler, use that `deviceId` to attribute the revenue.
```javascript
// Backend: webhook handler
export async function POST(req) {
const event = await req.json();
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
op.revenue(session.amount_total, {
deviceId: session.metadata.deviceId,
});
}
return Response.json({ received: true });
}
```
If your users are logged in, you can use `profileId` instead of `deviceId`. This simplifies the flow since you don't need to capture the device ID during checkout.
## Verify your setup [#verify]
Open your OpenPanel dashboard and trigger a few events manually. Add a product to your cart, then check the live events view to confirm the events are arriving with the correct properties.
For revenue tracking, you can test with a small transaction or use your payment provider's test mode. Look for your revenue events in the dashboard and verify the amounts match what you expect.
If events aren't appearing, check that your Client ID is correct and that ad blockers aren't interfering. The browser's network tab can help you confirm requests are being sent.
## Next steps
Once you have basic e-commerce tracking working, you can build purchase funnels to visualize conversion rates at each step. The [revenue tracking documentation](/docs/revenue-tracking) covers advanced patterns like subscription tracking and refunds. For a deeper understanding of attribution, read about how OpenPanel's [cookieless tracking](/articles/cookieless-analytics) works.
To learn more about tracking custom events in general, check out the [track custom events guide](/guides/track-custom-events) which covers event structure, properties, and common patterns.
<Faqs>
<FaqItem question="Does OpenPanel automatically calculate revenue metrics?">
Yes. Once you track revenue using the `revenue()` or `flushRevenue()` methods, OpenPanel calculates totals, averages, and breakdowns by source automatically. You can view these in the revenue section of your dashboard.
</FaqItem>
<FaqItem question="Should I track revenue from the frontend or backend?">
Backend tracking via webhooks is more accurate since it only records completed payments. Frontend tracking is faster to implement but may count abandoned checkouts. For production stores, backend tracking is recommended.
</FaqItem>
<FaqItem question="Can I track subscription revenue?">
Yes. Track subscription events like any other revenue event, including properties for plan name and billing period. The revenue tracking documentation covers subscription-specific patterns in detail.
</FaqItem>
<FaqItem question="How do I handle refunds?">
Track refunds as separate events with the refund amount. This lets you calculate net revenue by subtracting refunds from gross revenue in your reports.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,219 @@
---
title: "How to add analytics to Express"
description: "Add server-side analytics to your Express application with OpenPanel middleware. Track API requests, user actions, and custom events."
difficulty: beginner
timeToComplete: 8
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Add the middleware"
anchor: "middleware"
- name: "Track events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Express
Server-side analytics gives you reliable event tracking that cannot be blocked by ad blockers or browser extensions. The OpenPanel Express middleware wraps the JavaScript SDK and attaches it to every request, making it simple to track events throughout your application.
OpenPanel is an open-source alternative to Mixpanel and Amplitude. You get powerful analytics with full control over your data, and you can [self-host](/articles/how-to-self-host-openpanel) if privacy requirements demand it.
## Prerequisites
- An Express application
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID and Client Secret from the OpenPanel dashboard
Server-side tracking requires a `clientSecret` for authentication since the server cannot rely on browser CORS headers to verify the request origin.
## Install the SDK [#install]
The Express SDK is a lightweight middleware that creates an OpenPanel instance for each request. Install it with npm (pnpm and yarn work too).
```bash
npm install @openpanel/express
```
## Add the middleware [#middleware]
The middleware attaches the OpenPanel SDK to every request as `req.op`. Add it early in your middleware chain so it is available in all your route handlers.
```ts
import express from 'express';
import createOpenpanelMiddleware from '@openpanel/express';
const app = express();
app.use(express.json());
app.use(
createOpenpanelMiddleware({
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
})
);
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
```
You should store your credentials in environment variables rather than hardcoding them. This keeps secrets out of version control and makes it easy to use different credentials in development and production.
```ts
app.use(
createOpenpanelMiddleware({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
})
);
```
The middleware also forwards the client IP address and user-agent from incoming requests, so geographic and device data will be accurate even though events originate from your server.
## Track events [#events]
Once the middleware is in place, you can track events in any route handler by calling `req.op.track()`. The first argument is the event name and the second is an object of properties you want to attach.
```ts
app.post('/signup', async (req, res) => {
const { email, name } = req.body;
req.op.track('user_signed_up', {
email,
name,
source: 'website',
});
const user = await createUser({ email, name });
res.json({ success: true, user });
});
```
You can track any event that matters to your business. Common examples include form submissions, purchases, feature usage, and API errors.
```ts
app.post('/contact', async (req, res) => {
const { email, message } = req.body;
req.op.track('contact_form_submitted', {
email,
message_length: message.length,
});
await sendContactEmail(email, message);
res.json({ success: true });
});
```
### Automatic request tracking
The middleware can automatically track every request if you provide a `trackRequest` function. This is useful for monitoring API usage without manually adding tracking calls to each route.
```ts
app.use(
createOpenpanelMiddleware({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
trackRequest: (url) => url.startsWith('/api/'),
})
);
```
When `trackRequest` returns true, the middleware sends a `request` event with the URL, method, and query parameters.
## Identify users [#identify]
To associate events with specific users, use the `getProfileId` option in the middleware configuration. This function receives the request object and should return the user's ID.
```ts
app.use(
createOpenpanelMiddleware({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
getProfileId: (req) => req.user?.id,
})
);
```
You can also send user profile data with `req.op.identify()`. This updates the user's profile in OpenPanel with properties like name, email, and any custom attributes.
```ts
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
req.op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
req.op.track('user_logged_in', { method: 'email' });
res.json({ success: true, user });
});
```
### Increment profile properties
If you want to track cumulative values on a user profile, like login count or total purchases, use the `increment` method.
```ts
req.op.increment({
profileId: user.id,
property: 'login_count',
value: 1,
});
```
## Verify your setup [#verify]
Start your Express server and trigger a few events by making requests to your endpoints. Open the [OpenPanel dashboard](https://dashboard.openpanel.dev) and navigate to the Real-time view to see events as they arrive.
If events are not appearing, check your server logs for error responses from OpenPanel. Verify that both `clientId` and `clientSecret` are correct and that the middleware is added before your routes.
## TypeScript support
The Express SDK automatically extends the `Request` interface to include `req.op`. If your TypeScript configuration does not pick this up, you can extend the interface manually in a declaration file.
```ts
import { OpenPanel } from '@openpanel/express';
declare global {
namespace Express {
export interface Request {
op: OpenPanel;
}
}
}
```
## Next steps
The [Express SDK reference](/docs/sdks/express) covers all available options and methods. If you are using a different Node.js framework, the [Node.js tracking guide](/guides/nodejs-analytics) shows how to use the base SDK directly. For comparing OpenPanel to other analytics tools, see the [Mixpanel alternative](/compare/mixpanel-alternative) page.
<Faqs>
<FaqItem question="Why do I need a clientSecret for server-side tracking?">
Server-side tracking requires authentication because requests come from your server, not a browser with CORS restrictions. The clientSecret ensures events are properly authenticated and prevents unauthorized tracking from other sources.
</FaqItem>
<FaqItem question="Can I track events asynchronously?">
Yes. The OpenPanel SDK sends events asynchronously by default. Events are queued and dispatched in the background, so tracking calls will not block your route handlers or slow down response times.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking and data minimization. Server-side tracking gives you full control over what data you collect. With self-hosting, you eliminate international data transfer concerns entirely.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,267 @@
---
title: "How to add analytics to Android apps"
description: "Add privacy-first analytics to Android applications using OpenPanel's Kotlin SDK. Track events, identify users, and analyze behavior."
type: guide
difficulty: intermediate
timeToComplete: 10
date: 2025-12-15
lastUpdated: 2025-12-15
team: OpenPanel Team
steps:
- name: "Add the dependency"
anchor: "install"
- name: "Initialize OpenPanel"
anchor: "setup"
- name: "Track events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Track screen views"
anchor: "screenviews"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Android apps
This guide walks you through adding OpenPanel analytics to an Android application using the Kotlin SDK. You'll learn how to track events, identify users, and monitor screen views across your app.
OpenPanel works well for Android apps because it provides a lightweight, privacy-focused SDK that handles offline queuing and automatic system information collection. Unlike web SDKs, native apps require a client secret for authentication since CORS headers aren't available.
## Prerequisites
- An Android project (minSdkVersion 21+)
- An OpenPanel account
- Your Client ID and Client Secret from the [dashboard](https://dashboard.openpanel.dev)
## Add the dependency [#install]
Start by adding the OpenPanel SDK to your app's `build.gradle.kts` file. The SDK is available through standard Gradle dependency management.
```kotlin
dependencies {
implementation("dev.openpanel:openpanel:0.0.1")
}
```
The Kotlin SDK is currently in development, so check the [GitHub repository](https://github.com/Openpanel-dev/kotlin-sdk) for the latest version number before adding it to your project.
## Initialize OpenPanel [#setup]
Before you can track events, you need to initialize OpenPanel in your Application class. This ensures the SDK is available throughout your app and can properly manage its lifecycle.
```kotlin
import android.app.Application
import dev.openpanel.OpenPanel
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
OpenPanel.create(
context = this,
options = OpenPanel.Options(
clientId = "YOUR_CLIENT_ID",
clientSecret = "YOUR_CLIENT_SECRET"
)
)
}
}
```
You also need to register your Application class in the Android manifest so the system knows to use it.
```xml
<application
android:name=".MyApplication"
...>
</application>
```
If you're using dependency injection with Hilt or Dagger, you can provide OpenPanel as a singleton instead. This approach integrates better with modern Android architecture patterns.
```kotlin
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.openpanel.OpenPanel
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideOpenPanel(@ApplicationContext context: Context): OpenPanel {
return OpenPanel.create(
context,
OpenPanel.Options(
clientId = "YOUR_CLIENT_ID",
clientSecret = "YOUR_CLIENT_SECRET"
)
)
}
}
```
## Track events [#events]
Once OpenPanel is initialized, you can track events anywhere in your app by getting the SDK instance and calling the `track` method. Each event has a name and an optional map of properties.
```kotlin
import dev.openpanel.OpenPanel
class MainActivity : AppCompatActivity() {
private lateinit var op: OpenPanel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
op = OpenPanel.getInstance(this)
findViewById<Button>(R.id.signupButton).setOnClickListener {
op.track(
"button_clicked",
mapOf(
"button_name" to "signup",
"button_location" to "hero"
)
)
}
}
}
```
The SDK is thread-safe, so you can call `track` from any thread without additional synchronization. This is particularly useful when tracking events from background operations or coroutines.
If you want to attach properties to every event automatically, use `setGlobalProperties`. This is helpful for including app version, build number, or environment information.
```kotlin
op.setGlobalProperties(
mapOf(
"app_version" to BuildConfig.VERSION_NAME,
"build_number" to BuildConfig.VERSION_CODE.toString(),
"platform" to "Android"
)
)
```
## Identify users [#identify]
When a user logs in or you have information about who they are, call `identify` to associate their profile with tracked events. This enables user-level analytics and cohort analysis in your dashboard.
```kotlin
op.identify(
user.id,
mapOf(
"firstName" to user.firstName,
"lastName" to user.lastName,
"email" to user.email,
"plan" to user.plan
)
)
```
When a user logs out, clear their data so subsequent events aren't attributed to them.
```kotlin
fun logout() {
op.clear()
}
```
You can also increment numeric properties on user profiles. This is useful for tracking things like login counts or credits without needing to know the current value.
```kotlin
op.increment(user.id, "login_count", 1)
```
## Track screen views [#screenviews]
Screen view tracking helps you understand navigation patterns and which parts of your app get the most attention. The simplest approach is to create a base activity that tracks screen views automatically.
```kotlin
import androidx.appcompat.app.AppCompatActivity
import dev.openpanel.OpenPanel
abstract class BaseActivity : AppCompatActivity() {
protected lateinit var op: OpenPanel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
op = OpenPanel.getInstance(this)
}
override fun onResume() {
super.onResume()
op.track(
"screen_view",
mapOf("screen_name" to this::class.simpleName ?: "Unknown")
)
}
}
```
For Jetpack Compose, use a `LaunchedEffect` to track when a composable screen appears.
```kotlin
import androidx.compose.runtime.LaunchedEffect
import dev.openpanel.OpenPanel
@Composable
fun MainScreen(op: OpenPanel) {
LaunchedEffect(Unit) {
op.track(
"screen_view",
mapOf("screen_name" to "MainScreen")
)
}
// Your composable content
}
```
If you want the SDK to automatically track app lifecycle events like `app_opened` and `app_closed`, enable automatic tracking during initialization.
```kotlin
OpenPanel.create(
context,
OpenPanel.Options(
clientId = "YOUR_CLIENT_ID",
clientSecret = "YOUR_CLIENT_SECRET",
automaticTracking = true
)
)
```
## Verify your setup [#verify]
Run your Android app in an emulator or on a physical device and interact with a few screens and buttons. Open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the real-time view to see events arriving.
If events aren't appearing, check Logcat for error messages from the SDK. The most common issues are incorrect credentials or missing the `clientSecret` parameter. You can also use Android Studio's Network Profiler to verify that requests are being sent to OpenPanel's servers.
## Next steps
The [full Kotlin SDK reference](/docs/sdks/kotlin) covers additional options like event filtering and verbose logging. If you're building for multiple platforms, the [React Native guide](/guides/react-native-analytics) shows how to share analytics code across iOS and Android.
<Faqs>
<FaqItem question="Why do I need a client secret for native apps?">
Native apps can't use CORS headers for authentication like web apps do. The client secret provides server-side authentication to ensure your events are properly validated. Keep this secret secure and never expose it in client-side web code.
</FaqItem>
<FaqItem question="Does OpenPanel work with Jetpack Compose?">
Yes. OpenPanel works with both traditional Views and Jetpack Compose. For Compose, use LaunchedEffect to track screen views when a composable appears, as shown in the screen tracking section above.
</FaqItem>
<FaqItem question="Can I track events when the device is offline?">
Yes. The SDK queues events locally when there's no network connection and sends them automatically when connectivity is restored. You won't lose events if users go through tunnels or airplane mode.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with data minimization and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely. See the [cookieless analytics guide](/articles/cookieless-analytics) for more details.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,232 @@
---
title: "How to migrate from Google Analytics to OpenPanel"
description: "Switch from Google Analytics to OpenPanel in under an hour. Learn how to map GA4 events, set up parallel tracking, and gain privacy-first analytics."
difficulty: intermediate
timeToComplete: 45
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install OpenPanel"
anchor: "install"
- name: "Map your GA4 events"
anchor: "map-events"
- name: "Run parallel tracking"
anchor: "parallel"
- name: "Verify and compare data"
anchor: "verify"
- name: "Remove Google Analytics"
anchor: "remove"
---
# How to migrate from Google Analytics to OpenPanel
Migrating from Google Analytics to OpenPanel takes about 45 minutes for most websites. You'll end up with privacy-first analytics that doesn't require cookie consent banners, a simpler interface than GA4, and full ownership of your data.
OpenPanel uses a similar event-based tracking model to GA4, which makes the migration straightforward. The biggest difference is that OpenPanel is designed for privacy by default, using cookieless tracking that doesn't require consent under most privacy regulations.
## Prerequisites
- Existing Google Analytics 4 (GA4) setup
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Access to your website's code or Google Tag Manager
## Install OpenPanel [#install]
The first step is adding OpenPanel to your website alongside your existing Google Analytics installation. You'll run both in parallel during the migration to ensure nothing is lost.
Add the OpenPanel script to your website's HTML, replacing `YOUR_CLIENT_ID` with the client ID from your OpenPanel dashboard.
```html
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
The `trackScreenViews` option automatically tracks page views, similar to GA4's automatic page view tracking. The `trackOutgoingLinks` option tracks clicks to external domains, and `trackAttributes` enables declarative tracking via `data-track` attributes on HTML elements.
If you're using Google Tag Manager, create a new Custom HTML tag, paste the script above, set the trigger to All Pages, and publish your container.
For projects using a build system, you can also install the npm package with `npm install @openpanel/web`. See the full [Script Tag SDK documentation](/docs/sdks/script) for detailed configuration options.
## Map your GA4 events [#map-events]
OpenPanel uses a `track()` API that closely mirrors GA4's event structure. Most of your existing event tracking can be migrated with minimal changes.
### Page views
GA4 tracks page views automatically, and so does OpenPanel when you set `trackScreenViews: true`. If you need to track page views manually for single-page applications or specific scenarios, use the `screen_view` event.
```js
window.op('track', 'screen_view', {
path: window.location.pathname,
title: document.title,
});
```
### Custom events
The syntax for custom events is nearly identical between GA4 and OpenPanel. Your GA4 event that looks like this:
```js
gtag('event', 'button_click', {
button_name: 'signup',
button_location: 'hero'
});
```
Becomes this in OpenPanel:
```js
window.op('track', 'button_click', {
button_name: 'signup',
button_location: 'hero'
});
```
The event name and properties carry over directly. OpenPanel doesn't have the reserved event restrictions that GA4 has, so you can use any event name that makes sense for your application.
### User identification
GA4 uses `set user_properties` for user identification, while OpenPanel uses a dedicated `identify` method. This gives you richer user profiles and session history.
```js
window.op('identify', {
profileId: 'user_123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
```
The `profileId` is required and should be a unique identifier for the user, typically their user ID from your database.
### E-commerce events
GA4's e-commerce events like `purchase` and `add_to_cart` map directly to OpenPanel. The property structure stays the same.
```js
window.op('track', 'purchase', {
transaction_id: 'T12345',
value: 99.99,
currency: 'USD',
items: [{
item_id: 'SKU123',
item_name: 'Widget',
price: 99.99,
quantity: 1
}]
});
```
You can use the same event names and property structure you're already using in GA4, or take the opportunity to simplify your naming conventions.
## Run parallel tracking [#parallel]
Running both analytics tools simultaneously for one to two weeks lets you compare data and catch any gaps before fully migrating. Create a wrapper function that sends events to both platforms.
```js
function trackEvent(eventName, properties) {
// Send to GA4
if (typeof gtag !== 'undefined') {
gtag('event', eventName, properties);
}
// Send to OpenPanel
if (window.op) {
window.op('track', eventName, properties);
}
}
// Use this wrapper for all event tracking
trackEvent('button_click', { button_name: 'signup' });
```
Replace your existing `gtag()` calls with this wrapper function. This approach ensures you're capturing the same events in both systems and makes the final migration a simple change to the wrapper.
## Verify and compare data [#verify]
After a week of parallel tracking, compare the data between GA4 and OpenPanel to ensure everything is being captured correctly. Check both systems' real-time views to confirm events are flowing in.
In OpenPanel, navigate to the real-time view to see events as they happen. Compare the event counts and property values against GA4's Realtime report. Small discrepancies (within 5-10%) are normal due to differences in how each platform handles bot filtering and session attribution.
Pay particular attention to custom events and user identification. Open a few user profiles in OpenPanel to verify that the `identify` calls are linking events correctly across sessions.
If you're seeing significant discrepancies, check that your wrapper function is being called everywhere, that there are no JavaScript errors preventing tracking, and that both scripts are loading on all pages.
## Remove Google Analytics [#remove]
Once you've verified that OpenPanel is tracking correctly and you're comfortable with the data, remove Google Analytics from your site.
If you added the GA4 script directly to your HTML, remove the gtag.js script and initialization code.
```html
<!-- Remove this entire block -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
```
If you're using Google Tag Manager, disable or delete the GA4 Configuration tag and publish your container. You can keep GTM for other purposes if needed.
Update your wrapper function to only send to OpenPanel.
```js
function trackEvent(eventName, properties) {
if (window.op) {
window.op('track', eventName, properties);
}
}
```
Since OpenPanel doesn't use cookies by default, you may be able to remove your cookie consent banner entirely. This depends on whether you have other cookies or tracking scripts that require consent. Check the [cookieless analytics guide](/articles/cookieless-analytics) for details on GDPR compliance without cookie banners.
## What changes after migration
Moving from GA4 to OpenPanel means gaining some things and losing others. You'll get privacy-first tracking without cookies, full ownership of your data (especially if you [self-host](/articles/how-to-self-host-openpanel)), and a simpler interface for day-to-day analytics.
On the other hand, you won't be able to import historical data from GA4 since Google doesn't provide an easy export mechanism. If you rely heavily on Google Ads conversion tracking, you'll need to either keep GA4 running for that specific use case or use Google Ads' standalone conversion tracking pixel.
GA4's attribution modeling is more sophisticated than OpenPanel's, so if you depend on multi-touch attribution for ad spend optimization, consider your requirements carefully before fully migrating.
## Next steps
Once you're up and running with OpenPanel, explore the [funnel analysis](/articles/how-to-create-a-funnel) feature to track user journeys through your conversion paths. If you're interested in maximum data privacy and ownership, the self-hosting guide walks through running OpenPanel on your own infrastructure.
For framework-specific setup instructions, check out our guides:
- [Next.js analytics guide](/guides/nextjs-analytics) for Next.js applications
- [React analytics guide](/guides/react-analytics) for React applications
- [Node.js analytics guide](/guides/nodejs-analytics) for server-side tracking
- [Python analytics guide](/guides/python-analytics) for Python applications
<Faqs>
<FaqItem question="Can I import historical data from Google Analytics?">
Google Analytics doesn't provide an easy way to export historical event data. Most teams start fresh with OpenPanel, which gives you cleaner, privacy-compliant data going forward. Your GA4 data remains accessible in Google Analytics for the retention period you've configured.
</FaqItem>
<FaqItem question="How do I handle GA4 custom dimensions?">
OpenPanel uses properties instead of dimensions. Map your GA4 custom dimensions directly to OpenPanel event properties. For example, if you had a `user_type` custom dimension in GA4, include it as a property in your track calls: `window.op('track', 'event_name', { user_type: 'premium' })`.
</FaqItem>
<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>
</Faqs>

View File

@@ -0,0 +1,251 @@
---
title: "How to migrate from Mixpanel to OpenPanel"
description: "Switch from Mixpanel to OpenPanel in under 2 hours with this step-by-step migration guide."
difficulty: intermediate
timeToComplete: 90
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Export Mixpanel data"
anchor: "export"
- name: "Install OpenPanel SDK"
anchor: "install"
- name: "Map your events"
anchor: "map-events"
- name: "Import historical data"
anchor: "import"
- name: "Run in parallel"
anchor: "parallel"
- name: "Remove Mixpanel"
anchor: "remove"
---
# How to migrate from Mixpanel to OpenPanel
Migrating analytics tools sounds painful, but OpenPanel's API closely mirrors Mixpanel's patterns. Most implementations translate with minimal code changes. You'll map your existing events, optionally import historical data, and run both tools in parallel before cutting over.
OpenPanel gives you better privacy controls with cookieless tracking by default, transparent pricing without surprise overages, and the option to self-host. The migration typically takes 1-2 hours for code changes, plus a week or two of parallel tracking to verify data consistency.
## Prerequisites
- An existing Mixpanel implementation
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Access to your Mixpanel project settings for data export
- Access to your application codebase
## Export Mixpanel data [#export]
Before migrating, you'll want to export your historical data from Mixpanel. This step is optional if you're okay starting fresh, but most teams prefer to preserve their historical events for comparison.
Mixpanel provides an export API that returns events in JSON format. You'll need your API credentials from Project Settings in Mixpanel.
```bash
curl https://mixpanel.com/api/2.0/export \
-u YOUR_API_SECRET: \
-d 'from_date=2024-01-01' \
-d 'to_date=2024-12-15' \
-d 'event=["event1","event2"]' \
> mixpanel_export.json
```
Alternatively, you can export through Mixpanel's dashboard by navigating to Data Management, then Export. Select your date range and events, then download the file. Keep this export handy for the import step later.
## Install OpenPanel SDK [#install]
Install the OpenPanel SDK alongside Mixpanel. You'll remove Mixpanel later after verifying the migration.
For web applications, install the web SDK. This handles browser-specific features like automatic page view tracking and outgoing link tracking.
```bash
npm install @openpanel/web
```
For server-side applications or Node.js, use the core SDK instead. This version requires a client secret and is meant for trusted environments.
```bash
npm install @openpanel/sdk
```
If you're using Next.js, there's a dedicated package that handles both client and server tracking with proper component integration. Check the [SDK documentation](/docs/sdks/nextjs) for framework-specific setup.
## Map your events [#map-events]
OpenPanel's API follows similar patterns to Mixpanel, with a few naming convention differences. Mixpanel uses Title Case for events and properties, while OpenPanel uses snake_case.
Here's how basic event tracking translates. In Mixpanel, you might track a button click like this:
```js
mixpanel.track('Button Clicked', {
'Button Name': 'Sign Up',
'Button Location': 'Hero'
});
```
The equivalent in OpenPanel uses snake_case naming:
```js
op.track('button_clicked', {
button_name: 'Sign Up',
button_location: 'Hero'
});
```
User identification works similarly, but OpenPanel uses a structured object instead of separate method calls. Mixpanel's pattern splits identify and people.set:
```js
mixpanel.identify('user_123');
mixpanel.people.set({
'$email': 'user@example.com',
'$name': 'John Doe',
'Plan': 'Premium'
});
```
OpenPanel combines this into a single identify call with built-in fields for common properties:
```js
op.identify({
profileId: 'user_123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
properties: {
plan: 'Premium'
}
});
```
For super properties (global properties that attach to every event), Mixpanel uses `register` or `registerSuperProperties`. OpenPanel calls this `setGlobalProperties`, but the concept is identical:
```js
op.setGlobalProperties({
app_version: '1.0.0',
environment: 'production'
});
```
Incrementing user properties works the same way, just with different method names:
```js
op.increment({
profileId: user.id,
property: 'login_count',
value: 1
});
```
## Import historical data [#import]
OpenPanel can import your Mixpanel export using the SDK's server-side client. You'll need to transform the Mixpanel format slightly since Mixpanel uses Unix timestamps and different property names.
```js
import { OpenPanel } from '@openpanel/sdk';
const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
});
const mixpanelEvents = await loadMixpanelExport();
for (const event of mixpanelEvents) {
op.track(event.event, event.properties, {
profileId: event.distinct_id,
timestamp: new Date(event.time * 1000),
});
}
```
For user profiles, map Mixpanel's dollar-prefixed properties to OpenPanel's built-in fields:
```js
const mixpanelUsers = await loadMixpanelUsers();
for (const user of mixpanelUsers) {
op.identify({
profileId: user.distinct_id,
email: user.$email,
firstName: user.$first_name,
lastName: user.$last_name,
properties: {
plan: user.Plan,
signupDate: user['Signup Date'],
},
});
}
```
For large migrations, contact support. OpenPanel has tooling to help with bulk imports.
## Run in parallel [#parallel]
Before removing Mixpanel, run both tools simultaneously for 1-2 weeks. This lets you verify that event counts match and user identification is working correctly.
Create a simple wrapper function that sends events to both platforms:
```js
function trackEvent(eventName, properties) {
if (typeof mixpanel !== 'undefined') {
mixpanel.track(eventName, properties);
}
if (op) {
op.track(eventName, properties);
}
}
trackEvent('button_clicked', { button_name: 'Sign Up' });
```
After a week, compare Mixpanel's Live View with OpenPanel's real-time dashboard. Check that event counts are close (they won't be exact due to timing differences), user profiles are being created correctly, and any funnels you've set up show similar conversion rates.
## Remove Mixpanel [#remove]
Once you've verified data consistency, remove the Mixpanel SDK from your project. For npm packages, uninstall the dependency:
```bash
npm uninstall mixpanel-browser
```
Then search your codebase for any remaining `mixpanel.*` calls and replace them with the OpenPanel equivalents. If you were using the wrapper function from the parallel tracking step, you can now remove it and call OpenPanel directly.
```js
op.track('button_clicked', { button_name: 'Sign Up' });
```
Don't forget to remove any Mixpanel script tags if you were loading their SDK via CDN.
## Verify your setup [#verify]
After removing Mixpanel, monitor OpenPanel for a few days to ensure everything is tracking correctly. Check the real-time view to confirm events are flowing, verify that user profiles show the expected properties, and test any funnels or reports you've configured.
If events aren't appearing, double-check that you're using the correct client ID and that the SDK is initialized before any tracking calls. The browser console will show network requests to `api.openpanel.dev` if tracking is working.
## Next steps
The [SDK documentation](/docs) covers advanced features like custom event properties, server-side tracking, and framework-specific integrations. If you're coming from Mixpanel because of pricing concerns, you might also want to explore [self-hosting](/articles/how-to-self-host-openpanel) for complete control over your data and costs.
For framework-specific setup instructions, check out our guides:
- [Next.js analytics guide](/guides/nextjs-analytics) for Next.js applications
- [React analytics guide](/guides/react-analytics) for React applications
- [Express analytics guide](/guides/express-analytics) for Express.js applications
- [Python analytics guide](/guides/python-analytics) for Python applications
<Faqs>
<FaqItem question="Can I import my Mixpanel historical data?">
Yes. OpenPanel can import Mixpanel exports using the SDK's server-side client. For large migrations with millions of events, contact support for assistance with bulk imports.
</FaqItem>
<FaqItem question="Will my Mixpanel funnels work in OpenPanel?">
You'll need to recreate funnels in OpenPanel's dashboard, but the concepts map directly. Select the same events in sequence and configure your date range and filters. Conversion logic works the same way.
</FaqItem>
<FaqItem question="How long does the full migration take?">
Most migrations take 1-2 hours for code changes. Plan for an additional 1-2 weeks of parallel tracking to verify data consistency before fully switching over.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel uses cookieless tracking by default, which means you don't need cookie consent banners for basic analytics under most privacy regulations. With self-hosting, you also eliminate international data transfer concerns entirely.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,255 @@
---
title: "How to add analytics to Next.js"
description: "Add privacy-first analytics to your Next.js app in under 5 minutes with OpenPanel."
difficulty: beginner
timeToComplete: 8
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Add OpenPanel to your layout"
anchor: "setup"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Next.js
This guide walks you through adding OpenPanel to a Next.js application. By the end, you'll have automatic page view tracking, custom event tracking, and user identification working in your app.
OpenPanel works with both the App Router and Pages Router. It uses cookieless tracking by default, so you won't need cookie consent banners for basic analytics. If you're looking for a privacy-focused alternative to Mixpanel or Google Analytics, this is a straightforward setup.
## Prerequisites
- A Next.js project (App Router or Pages Router)
- An OpenPanel account
- Your Client ID from the [OpenPanel dashboard](https://dashboard.openpanel.dev/onboarding)
## Install the SDK [#install]
Start by installing the OpenPanel Next.js package. This SDK is a thin wrapper around the core OpenPanel library with Next.js-specific components for easier integration.
```bash
npm install @openpanel/nextjs
```
If you prefer pnpm or yarn, those work too.
## Add OpenPanel to your layout [#setup]
Before tracking anything, you need to add the OpenPanelComponent to your app's root. This loads the tracking script and makes the SDK available throughout your application.
For App Router projects, add the component to your root layout file. The component should be placed inside the body tag, and setting `trackScreenViews` to true enables automatic page view tracking as users navigate your app.
```tsx
// app/layout.tsx
import { OpenPanelComponent } from '@openpanel/nextjs';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<OpenPanelComponent
clientId="your-client-id"
trackScreenViews={true}
/>
{children}
</body>
</html>
);
}
```
For Pages Router projects, the setup is nearly identical. Add the component to your `_app.tsx` file instead.
```tsx
// pages/_app.tsx
import { OpenPanelComponent } from '@openpanel/nextjs';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<OpenPanelComponent
clientId="your-client-id"
trackScreenViews={true}
/>
<Component {...pageProps} />
</>
);
}
```
You can also enable `trackOutgoingLinks` to automatically track when users click external links, or `trackAttributes` to track elements with data attributes.
## Track custom events [#events]
Page views only tell part of the story. To understand how users interact with your product, you'll want to track custom events like button clicks, form submissions, or feature usage.
In client components, use the `useOpenPanel` hook. This gives you access to the tracking methods anywhere in your component tree.
```tsx
'use client';
import { useOpenPanel } from '@openpanel/nextjs';
export function SignupButton() {
const op = useOpenPanel();
return (
<button
type="button"
onClick={() => op.track('signup_clicked', { location: 'header' })}
>
Sign up
</button>
);
}
```
For server-side tracking, you'll need to create an SDK instance with your client secret. This is useful for tracking events in API routes, webhooks, or server actions.
```tsx
// lib/op.ts
import { OpenPanel } from '@openpanel/nextjs';
export const op = new OpenPanel({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
});
```
Never expose your client secret on the client side. Keep it in server-only code.
With the instance created, you can track events from anywhere on the server.
```tsx
// app/api/webhook/route.ts
import { op } from '@/lib/op';
export async function POST(request: Request) {
const data = await request.json();
op.track('webhook_received', {
source: data.source,
event_type: data.type,
});
return Response.json({ success: true });
}
```
If you're running on Vercel or another serverless platform, wrap your tracking calls with `waitUntil` to ensure events are sent before the function terminates.
## Identify users [#identify]
Anonymous tracking is useful, but identifying users unlocks more valuable insights. You can track behavior across sessions, segment users by properties, and build cohort analyses.
In client components, call `identify` after a user logs in or when you have their information available.
```tsx
'use client';
import { useOpenPanel } from '@openpanel/nextjs';
import { useEffect } from 'react';
export function UserProfile({ user }) {
const op = useOpenPanel();
useEffect(() => {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
},
});
}, [user]);
return <div>Welcome, {user.firstName}!</div>;
}
```
If you already have user data on the server, the `IdentifyComponent` is a cleaner approach. It renders nothing visible but handles identification when the component mounts.
```tsx
// app/dashboard/layout.tsx
import { IdentifyComponent } from '@openpanel/nextjs';
export default async function DashboardLayout({ children }) {
const user = await getCurrentUser();
return (
<>
<IdentifyComponent
profileId={user.id}
firstName={user.firstName}
lastName={user.lastName}
email={user.email}
properties={{
plan: user.plan,
}}
/>
{children}
</>
);
}
```
## Verify your setup [#verify]
Open your Next.js app in the browser and navigate between a few pages. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the real-time view. You should see page view events appearing within seconds.
If events aren't showing up, check the browser console for errors. The most common issues are an incorrect client ID or ad blockers intercepting requests. You can solve the ad blocker problem by proxying events through your own server.
To set up a proxy, create a catch-all API route that forwards requests to OpenPanel.
```tsx
// app/api/[...op]/route.ts
import { createRouteHandler } from '@openpanel/nextjs/server';
export const { GET, POST } = createRouteHandler();
```
Then update your OpenPanelComponent to use the proxy endpoint.
```tsx
<OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId="your-client-id"
trackScreenViews={true}
/>
```
This routes all tracking requests through your domain, making them invisible to browser extensions that block third-party analytics.
## Next steps
The [Next.js SDK reference](/docs/sdks/nextjs) covers additional features like global properties, event filtering, and incrementing user properties. If you're interested in understanding how OpenPanel handles privacy, read our article on [cookieless analytics](/articles/cookieless-analytics).
<Faqs>
<FaqItem question="Does OpenPanel work with both App Router and Pages Router?">
Yes. The setup is nearly identical for both. Add OpenPanelComponent to your root layout for App Router or to _app.tsx for Pages Router. All tracking features work the same way regardless of which router you use.
</FaqItem>
<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="How do I avoid ad blockers?">
Set up the proxy route handler as shown in the verification section. This routes tracking requests through your own domain, which ad blockers don't typically block.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,324 @@
---
title: "How to Track Events with Node.js"
description: "Add server-side analytics to your Node.js application. Track events, identify users, and analyze behavior with OpenPanel's JavaScript SDK."
difficulty: beginner
timeToComplete: 7
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Initialize OpenPanel"
anchor: "setup"
- name: "Track events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
## Introduction
Server-side analytics gives you complete control over what data you track and ensures events are never blocked by ad blockers. OpenPanel's JavaScript SDK works perfectly in Node.js environments, allowing you to track events from your backend, API routes, and background jobs.
OpenPanel is an open-source alternative to Mixpanel and Amplitude, giving you powerful server-side analytics without compromising user privacy.
## Prerequisites
- Node.js project set up
- OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID and Client Secret from the OpenPanel dashboard
> **Important:** Server-side tracking requires a `clientSecret` for authentication.
## Step 1: Install the SDK
Install the OpenPanel JavaScript SDK:
```bash
npm install @openpanel/sdk
```
Or with pnpm:
```bash
pnpm install @openpanel/sdk
```
## Step 2: Initialize OpenPanel
Create an OpenPanel instance with your credentials:
```js title="lib/op.js"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET', // Required for server-side
});
```
> **Security Note:** Never expose your `clientSecret` in client-side code. Use environment variables in production.
### Using environment variables
```js title="lib/op.js"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: process.env.OPENPANEL_CLIENT_ID,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET,
});
```
```bash title=".env"
OPENPANEL_CLIENT_ID=your-client-id
OPENPANEL_CLIENT_SECRET=your-client-secret
```
## Step 3: Track events
Track events throughout your Node.js application:
```js title="routes/api/signup.js"
import { op } from '@/lib/op';
export async function POST(request) {
const { email, name } = await request.json();
// Track signup event
op.track('user_signed_up', {
email,
name,
source: 'website',
});
// Your signup logic
const user = await createUser({ email, name });
return Response.json({ success: true, user });
}
```
### Track API requests
```js title="middleware/analytics.js"
import { op } from '@/lib/op';
export function analyticsMiddleware(req, res, next) {
// Track API request
op.track('api_request', {
method: req.method,
path: req.path,
status_code: res.statusCode,
});
next();
}
```
### Track background jobs
```js title="jobs/send-email.js"
import { op } from '@/lib/op';
export async function sendEmailJob(userId, emailData) {
try {
await sendEmail(emailData);
op.track('email_sent', {
user_id: userId,
email_type: emailData.type,
success: true,
});
} catch (error) {
op.track('email_failed', {
user_id: userId,
email_type: emailData.type,
error: error.message,
});
}
}
```
## Step 4: Identify users
To identify users and track their behavior:
```js title="routes/api/login.js"
import { op } from '@/lib/op';
export async function POST(request) {
const { email, password } = await request.json();
const user = await authenticateUser(email, password);
// Identify the user
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
// Track login event
op.track('user_logged_in', {
user_id: user.id,
method: 'email',
});
return Response.json({ success: true, user });
}
```
### Track user actions
```js title="routes/api/purchase.js"
import { op } from '@/lib/op';
export async function POST(request) {
const { userId, productId, amount } = await request.json();
// Track purchase event
op.track('purchase_completed', {
user_id: userId,
product_id: productId,
amount,
currency: 'USD',
}, {
profileId: userId, // Associate event with user
});
// Your purchase logic
await processPurchase(userId, productId, amount);
return Response.json({ success: true });
}
```
## Verify your setup
1. Make requests to your API endpoints that trigger events
2. Run background jobs that track events
3. Open your [OpenPanel dashboard](https://dashboard.openpanel.dev)
4. Check the Real-time view to see events coming in
**Not seeing events?**
- Check server logs for errors
- Verify your Client ID and Client Secret are correct
- Ensure `clientSecret` is provided (required for server-side)
- Check network requests in your server logs
## Common Patterns
### Track webhook events
```js title="routes/api/webhook.js"
import { op } from '@/lib/op';
export async function POST(request) {
const payload = await request.json();
op.track('webhook_received', {
source: payload.source,
event_type: payload.type,
timestamp: new Date().toISOString(),
});
// Process webhook
await processWebhook(payload);
return Response.json({ success: true });
}
```
### Set global properties
```js title="lib/op.js"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: process.env.OPENPANEL_CLIENT_ID,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET,
globalProperties: {
app_version: process.env.APP_VERSION,
environment: process.env.NODE_ENV,
server_region: process.env.SERVER_REGION,
},
});
```
### Track errors
```js title="middleware/error-handler.js"
import { op } from '@/lib/op';
export function errorHandler(err, req, res, next) {
// Track error event
op.track('error_occurred', {
error_message: err.message,
error_stack: err.stack,
path: req.path,
method: req.method,
});
// Your error handling logic
res.status(500).json({ error: 'Internal server error' });
}
```
### Increment user properties
```js
import { op } from '@/lib/op';
// Increment login count
op.increment({
profileId: user.id,
property: 'login_count',
value: 1,
});
```
## Serverless & Vercel
If you're using serverless functions (like Vercel), use `waitUntil` to ensure events are tracked before the function completes:
```js title="api/track.js"
import { waitUntil } from '@vercel/functions';
import { op } from '@/lib/op';
export default async function handler(req, res) {
// Returns response immediately while keeping function alive
waitUntil(op.track('important_event', { foo: 'bar' }));
return res.status(200).json({ success: true });
}
```
## Next Steps
- [Full JavaScript SDK reference](/docs/sdks/javascript)
- [How to Add Analytics to Express](/guides/express-analytics)
- [Compare OpenPanel to Mixpanel](/compare/mixpanel-alternative)
## FAQ
### Why do I need a clientSecret for server-side tracking?
Server-side tracking requires authentication since we can't use CORS headers. The `clientSecret` ensures your events are properly authenticated and prevents unauthorized tracking.
### Can I track events asynchronously?
Yes! The OpenPanel SDK tracks events asynchronously by default. Events are queued and sent in the background, so they won't block your application.
### Is OpenPanel GDPR compliant?
Yes! OpenPanel is designed with privacy in mind. Server-side tracking gives you complete control over what data you collect. Check out our [cookieless analytics guide](/articles/cookieless-analytics) for more information.

View File

@@ -0,0 +1,354 @@
---
title: "How to add analytics to Nuxt"
description: "Add privacy-first analytics to your Nuxt app in under 5 minutes with OpenPanel's official Nuxt module."
difficulty: beginner
timeToComplete: 5
date: 2025-01-07
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the module"
anchor: "install"
- name: "Configure the module"
anchor: "setup"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Set up server-side tracking"
anchor: "server"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Nuxt
This guide walks you through adding OpenPanel to a Nuxt 3 application. The official `@openpanel/nuxt` module makes integration effortless with auto-imported composables, automatic page view tracking, and a built-in proxy option to bypass ad blockers.
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It uses cookieless tracking by default, so you won't need cookie consent banners for basic analytics.
## Prerequisites
- A Nuxt 3 project
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the module [#install]
Start by installing the OpenPanel Nuxt module. This package includes everything you need for client-side tracking, including the auto-imported `useOpenPanel` composable.
```bash
npm install @openpanel/nuxt
```
If you prefer pnpm or yarn, those work too.
## Configure the module [#setup]
Add the module to your `nuxt.config.ts` and configure it with your Client ID. The module automatically sets up page view tracking and makes the `useOpenPanel` composable available throughout your app.
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
},
});
```
That's it. Page views are now being tracked automatically as users navigate your app.
### Configuration options
| Option | Default | Description |
|--------|---------|-------------|
| `clientId` | — | Your OpenPanel client ID (required) |
| `apiUrl` | `https://api.openpanel.dev` | The API URL to send events to |
| `trackScreenViews` | `true` | Automatically track page views |
| `trackOutgoingLinks` | `true` | Track clicks on external links |
| `trackAttributes` | `true` | Track elements with `data-track` attributes |
| `trackHashChanges` | `false` | Track hash changes in URL |
| `disabled` | `false` | Disable all tracking |
| `proxy` | `false` | Route events through your server |
### Using environment variables
For production applications, store your Client ID in environment variables.
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: process.env.NUXT_PUBLIC_OPENPANEL_CLIENT_ID,
trackScreenViews: true,
},
});
```
```bash title=".env"
NUXT_PUBLIC_OPENPANEL_CLIENT_ID=your-client-id
```
## Track custom events [#events]
Page views only tell part of the story. To understand how users interact with your product, track custom events like button clicks, form submissions, or feature usage.
### Using the composable
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component without importing anything.
```vue title="components/SignupButton.vue"
<script setup>
const op = useOpenPanel();
function handleClick() {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
}
</script>
<template>
<button type="button" @click="handleClick">Sign Up</button>
</template>
```
### Accessing via useNuxtApp
You can also access the OpenPanel instance through `useNuxtApp()` if you prefer.
```vue
<script setup>
const { $openpanel } = useNuxtApp();
$openpanel.track('my_event', { foo: 'bar' });
</script>
```
### Track form submissions
Form tracking helps you understand conversion rates and identify where users drop off.
```vue title="components/ContactForm.vue"
<script setup>
const op = useOpenPanel();
const email = ref('');
async function handleSubmit() {
op.track('form_submitted', {
form_name: 'contact',
form_location: 'homepage',
});
// Your form submission logic
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="Enter your email" />
<button type="submit">Submit</button>
</form>
</template>
```
### Use data attributes for declarative tracking
The SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
```vue
<template>
<button
data-track="button_clicked"
data-track-button_name="signup"
data-track-button_location="hero"
>
Sign Up
</button>
</template>
```
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
## Identify users [#identify]
Anonymous tracking is useful, but identifying users unlocks more valuable insights. You can track behavior across sessions, segment users by properties, and build cohort analyses.
Call `identify` after a user logs in or when you have their information available.
```vue title="components/UserProfile.vue"
<script setup>
const op = useOpenPanel();
const props = defineProps(['user']);
watch(() => props.user, (user) => {
if (user) {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
}
}, { immediate: true });
</script>
<template>
<div>Welcome, {{ user?.firstName }}!</div>
</template>
```
### Set global properties
Properties set with `setGlobalProperties` are included with every event. This is useful for app version tracking, feature flags, or A/B test variants.
```vue title="app.vue"
<script setup>
const op = useOpenPanel();
onMounted(() => {
op.setGlobalProperties({
app_version: '1.0.0',
environment: useRuntimeConfig().public.environment,
});
});
</script>
```
### Clear user data on logout
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
```vue title="components/LogoutButton.vue"
<script setup>
const op = useOpenPanel();
function handleLogout() {
op.clear();
navigateTo('/login');
}
</script>
<template>
<button @click="handleLogout">Logout</button>
</template>
```
## Set up server-side tracking [#server]
For tracking events in server routes, API endpoints, or server middleware, use the `@openpanel/sdk` package. Server-side tracking requires a client secret for authentication.
### Install the SDK
```bash
npm install @openpanel/sdk
```
### Create a server instance
```ts title="server/utils/op.ts"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
});
```
### Track events in server routes
```ts title="server/api/webhook.post.ts"
export default defineEventHandler(async (event) => {
const body = await readBody(event);
await op.track('webhook_received', {
source: body.source,
event_type: body.type,
});
return { success: true };
});
```
Never expose your client secret on the client side. Keep it in server-only code.
### Awaiting in serverless environments
If you're deploying to a serverless platform like Vercel or Netlify, make sure to await the tracking call to ensure it completes before the function terminates.
```ts
export default defineEventHandler(async (event) => {
// Always await in serverless environments
await op.track('my_server_event', { foo: 'bar' });
return { message: 'Event logged!' };
});
```
## Bypass ad blockers with proxy [#proxy]
Many ad blockers block requests to third-party analytics domains. The Nuxt module includes a built-in proxy that routes events through your own server.
Enable the proxy option in your configuration:
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
proxy: true, // Routes events through /api/openpanel/*
},
});
```
When `proxy: true` is set:
- The module automatically sets `apiUrl` to `/api/openpanel`
- A server handler is registered at `/api/openpanel/**`
- All tracking requests route through your Nuxt server
This makes tracking requests invisible to browser extensions that block third-party analytics.
## Verify your setup [#verify]
Open your Nuxt app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing within seconds.
If events aren't showing up, check the browser console for errors. The most common issues are:
- Incorrect client ID
- Ad blockers intercepting requests (enable the proxy option)
- Client ID exposed in server-only code
The Network tab in your browser's developer tools can help you confirm that requests are being sent.
## Next steps
The [Nuxt SDK reference](/docs/sdks/nuxt) covers additional features like incrementing user properties and event filtering. If you're interested in understanding how OpenPanel handles privacy, read our article on [cookieless analytics](/articles/cookieless-analytics).
<Faqs>
<FaqItem question="Does OpenPanel work with Nuxt 3 and Nuxt 4?">
Yes. The `@openpanel/nuxt` module supports both Nuxt 3 and Nuxt 4. It uses Nuxt's module system and auto-imports, so everything works seamlessly with either version.
</FaqItem>
<FaqItem question="Is the useOpenPanel composable auto-imported?">
Yes. The module automatically registers the `useOpenPanel` composable, so you can use it in any component without importing it. You can also access the instance via `useNuxtApp().$openpanel`.
</FaqItem>
<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="How do I avoid ad blockers?">
Enable the `proxy: true` option in your configuration. This routes all tracking requests through your Nuxt server at `/api/openpanel/*`, which ad blockers don't typically block.
</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>
</Faqs>

View File

@@ -0,0 +1,209 @@
---
title: "How to add analytics to Python"
description: "Add server-side analytics to your Python application with OpenPanel's Python SDK."
type: guide
difficulty: beginner
timeToComplete: 7
date: 2025-12-15
lastUpdated: 2025-12-15
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Initialize OpenPanel"
anchor: "initialize"
- name: "Track events"
anchor: "track-events"
- name: "Identify users"
anchor: "identify-users"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Python
This guide walks you through adding server-side analytics to any Python application. You'll install the OpenPanel SDK, configure it with your credentials, track custom events, and identify users.
Server-side tracking gives you complete control over what data you collect and ensures events are never blocked by browser extensions or ad blockers. The Python SDK works with Django, Flask, FastAPI, and any other Python framework or script.
## Prerequisites
- A Python project
- An OpenPanel account
- Your Client ID and Client Secret from the dashboard
## Install the SDK [#install]
Start by installing the OpenPanel package from PyPI.
```bash
pip install openpanel
```
If you're using Poetry, you can run `poetry add openpanel` instead.
## Initialize OpenPanel [#initialize]
Create a shared module for your OpenPanel instance. This approach lets you import the same configured instance throughout your application.
```python
# lib/op.py
import os
from openpanel import OpenPanel
op = OpenPanel(
client_id=os.getenv("OPENPANEL_CLIENT_ID"),
client_secret=os.getenv("OPENPANEL_CLIENT_SECRET")
)
```
Server-side tracking requires both a client ID and client secret for authentication. Add these to your environment variables.
```bash
# .env
OPENPANEL_CLIENT_ID=your-client-id
OPENPANEL_CLIENT_SECRET=your-client-secret
```
You can also pass global properties during initialization. These properties are included with every event automatically.
```python
op = OpenPanel(
client_id=os.getenv("OPENPANEL_CLIENT_ID"),
client_secret=os.getenv("OPENPANEL_CLIENT_SECRET"),
global_properties={
"app_version": "1.0.0",
"environment": os.getenv("ENVIRONMENT", "production")
}
)
```
## Track events [#track-events]
Use the `track` method to record events. The first argument is the event name, and the second is an optional dictionary of properties.
```python
from lib.op import op
# Track a simple event
op.track("button_clicked")
# Track with properties
op.track("purchase_completed", {
"product_id": "123",
"price": 99.99,
"currency": "USD"
})
```
When tracking events in request handlers, you'll typically pull data from the request and track it alongside your business logic. Here's an example in a Django view.
```python
from lib.op import op
def signup_view(request):
if request.method == 'POST':
email = request.POST.get('email')
name = request.POST.get('name')
user = create_user(email, name)
op.track("user_signed_up", {
"email": email,
"source": "website"
})
return JsonResponse({"success": True})
```
The same pattern works in Flask and FastAPI. Import your OpenPanel instance and call `track` wherever you need to record an event.
You can also track events from background tasks. This is useful for monitoring async jobs, email delivery, and scheduled tasks.
```python
from celery import shared_task
from lib.op import op
@shared_task
def send_email_task(user_id, email_type):
try:
send_email(user_id, email_type)
op.track("email_sent", {
"user_id": user_id,
"email_type": email_type
})
except Exception as e:
op.track("email_failed", {
"user_id": user_id,
"error": str(e)
})
```
## Identify users [#identify-users]
The `identify` method associates a user profile with their ID. Call this after authentication to link subsequent events to that user.
```python
from lib.op import op
def login_view(request):
user = authenticate_user(request)
op.identify(user.id, {
"firstName": user.first_name,
"lastName": user.last_name,
"email": user.email,
"tier": user.plan
})
op.track("user_logged_in", {"method": "email"})
return JsonResponse({"success": True})
```
To track an event for a specific user without calling identify first, pass the `profile_id` parameter to the track method.
```python
op.track("purchase_completed", {
"product_id": "123",
"amount": 99.99
}, profile_id="user_456")
```
You can increment numeric properties on user profiles. This is useful for counters like login count or total purchases.
```python
op.increment({
"profile_id": "user_456",
"property": "login_count",
"value": 1
})
```
## Verify your setup [#verify]
Run your Python application and trigger a few events. Open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and navigate to the real-time view. You should see your events appearing within seconds.
If events aren't showing up, check that your client ID and client secret are correct. Server-side tracking won't work without the client secret. Review your application logs for any error messages from the SDK.
## Next steps
The [Python SDK reference](/docs/sdks/python) covers additional configuration options like event filtering and disabling tracking. If you're also tracking client-side events, you might want to read about [cookieless analytics](/articles/cookieless-analytics) to understand how OpenPanel handles privacy without cookies.
<Faqs>
<FaqItem question="Why do I need a client secret for server-side tracking?">
Server-side tracking can't rely on browser-based authentication like CORS headers. The client secret authenticates your requests and prevents unauthorized parties from sending events to your project.
</FaqItem>
<FaqItem question="Is the SDK thread-safe?">
Yes. The OpenPanel Python SDK is thread-safe and you can use a single instance across multiple threads. This makes it safe to use in threaded web servers and background task workers.
</FaqItem>
<FaqItem question="Does OpenPanel work with async Python?">
Yes. The SDK tracks events asynchronously by default, so it won't block your async request handlers. Events are queued and sent in the background.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and support for data subject rights. Server-side tracking gives you complete control over what data you collect.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,295 @@
---
title: "How to add analytics to React"
description: "Add privacy-first analytics to your React application with OpenPanel's Web SDK. Track page views, custom events, and user behavior."
difficulty: beginner
timeToComplete: 8
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Create an OpenPanel instance"
anchor: "setup"
- name: "Track page views"
anchor: "pageviews"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
Adding analytics to your React application helps you understand how users interact with your app. OpenPanel's Web SDK works seamlessly with React and React Router, giving you page view tracking, custom events, and user identification without the complexity of dedicated React bindings.
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It provides powerful insights while respecting user privacy through cookieless tracking by default.
## Prerequisites
- A React project (Create React App, Vite, or similar)
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the SDK [#install]
The OpenPanel Web SDK is a lightweight package that works in any JavaScript environment, including React. Install it using npm, and pnpm or yarn work the same way.
```bash
npm install @openpanel/web
```
## Create an OpenPanel instance [#setup]
Create a dedicated file for your OpenPanel instance. This keeps your analytics configuration centralized and makes the instance easy to import throughout your application.
```ts title="src/lib/op.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
The `trackScreenViews` option automatically tracks page views when the URL changes. This works with React Router, TanStack Router, and other client-side routing solutions. The `trackAttributes` option enables declarative tracking using `data-track` attributes on HTML elements.
### Using environment variables
For production applications, store your Client ID in environment variables. Vite and Create React App handle these differently.
```ts title="src/lib/op.ts (Vite)"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
```ts title="src/lib/op.ts (Create React App)"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: process.env.REACT_APP_OPENPANEL_CLIENT_ID,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
### Initialize on app load
Import the OpenPanel instance in your app's entry point to ensure it initializes when your application loads. This is all you need to start tracking page views automatically.
```tsx title="src/main.tsx"
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './lib/op'; // Initialize OpenPanel
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
```
## Track page views [#pageviews]
With `trackScreenViews: true`, OpenPanel automatically tracks page views when the browser's URL changes. This works out of the box with React Router and other routing libraries that use the History API.
If you need to track page views manually or want more control over the data sent with each view, you can create a component that listens to route changes.
```tsx title="src/components/PageTracker.tsx"
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { op } from '../lib/op';
export function PageTracker() {
const location = useLocation();
useEffect(() => {
op.track('screen_view', {
path: location.pathname,
search: location.search,
});
}, [location.pathname, location.search]);
return null;
}
```
Add this component near the root of your application, inside your router context. Note that if you use this approach, you should set `trackScreenViews: false` in your OpenPanel configuration to avoid duplicate tracking.
## Track custom events [#events]
Import the OpenPanel instance wherever you need to track events. The `track` method accepts an event name and an optional properties object.
```tsx title="src/components/SignupButton.tsx"
import { op } from '../lib/op';
export function SignupButton() {
const handleClick = () => {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
};
return (
<button type="button" onClick={handleClick}>
Sign Up
</button>
);
}
```
### Track form submissions
Form tracking helps you understand conversion rates and identify where users drop off.
```tsx title="src/components/ContactForm.tsx"
import { useState } from 'react';
import { op } from '../lib/op';
export function ContactForm() {
const [email, setEmail] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
op.track('form_submitted', {
form_name: 'contact',
form_location: 'homepage',
});
// Your form submission logic
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
/>
<button type="submit">Submit</button>
</form>
);
}
```
### Use data attributes for declarative tracking
The Web SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
```tsx
<button
data-track="button_clicked"
data-track-button_name="signup"
data-track-button_location="hero"
>
Sign Up
</button>
```
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
## Identify users [#identify]
Once a user logs in or provides identifying information, call `identify` to associate their activity with a profile. This enables user-level analytics and cohort analysis.
```tsx title="src/hooks/useAuth.ts"
import { useEffect } from 'react';
import { op } from '../lib/op';
export function useAuth(user: User | null) {
useEffect(() => {
if (user) {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
}
}, [user]);
}
```
### Clear user data on logout
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
```tsx title="src/components/LogoutButton.tsx"
import { op } from '../lib/op';
export function LogoutButton({ onLogout }: { onLogout: () => void }) {
const handleLogout = () => {
op.clear();
onLogout();
};
return <button onClick={handleLogout}>Logout</button>;
}
```
### Set global properties
Properties set with `setGlobalProperties` are included with every event. This is useful for app version tracking, feature flags, or A/B test variants.
```tsx title="src/App.tsx"
import { useEffect } from 'react';
import { op } from './lib/op';
export default function App() {
useEffect(() => {
op.setGlobalProperties({
app_version: '1.0.0',
environment: import.meta.env.MODE,
});
}, []);
return <div>{/* Your app */}</div>;
}
```
## Verify your setup [#verify]
Open your React app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing.
If events aren't appearing, check the browser console for errors. Verify your Client ID is correct and ensure ad blockers aren't blocking requests to the OpenPanel API. The Network tab in your browser's developer tools can help you confirm that requests are being sent.
## Next steps
The Web SDK has additional features like property incrementing and event filtering. Read the full [Web SDK documentation](/docs/sdks/web) for the complete API reference.
For server-side tracking in your React application's API routes, see the [Node.js analytics guide](/guides/nodejs-analytics) which covers the `@openpanel/sdk` package.
<Faqs>
<FaqItem question="Does OpenPanel work with React Router?">
Yes. OpenPanel automatically tracks page views when the URL changes, which works with React Router and other routing libraries that use the History API. Set `trackScreenViews: true` in your configuration.
</FaqItem>
<FaqItem question="Can I use OpenPanel with class components?">
Yes. Import the OpenPanel instance directly and call its methods. The instance is framework-agnostic and works in any JavaScript context.
</FaqItem>
<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>
</Faqs>

View File

@@ -0,0 +1,216 @@
---
title: "How to add analytics to React Native"
description: "Add privacy-first analytics to your React Native app with OpenPanel. Track screen views, custom events, and user behavior across iOS and Android."
difficulty: intermediate
timeToComplete: 10
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Initialize OpenPanel"
anchor: "setup"
- name: "Track screen views"
anchor: "screenviews"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to React Native
Adding analytics to your React Native app helps you understand how users interact with your product across both iOS and Android. This guide walks you through setting up OpenPanel to track screen views, custom events, and user behavior in about ten minutes.
OpenPanel works well with React Native because it handles the complexities of native environments for you. The SDK automatically captures app version, build number, and install referrer on Android. It also queues events when the device is offline and sends them when connectivity returns.
## Prerequisites
- A React Native project (Expo or bare React Native)
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID and Client Secret from the OpenPanel dashboard
## Install the SDK [#install]
Start by adding the OpenPanel React Native package and its Expo dependencies. The SDK relies on `expo-application` for version information and `expo-constants` for user-agent data.
```bash
npm install @openpanel/react-native
npx expo install expo-application expo-constants
```
You can also use pnpm or yarn if that's your preference. The Expo packages work in both Expo and bare React Native projects.
## Initialize OpenPanel [#setup]
Create an OpenPanel instance that you'll use throughout your app. React Native requires a `clientSecret` for authentication since native apps can't use CORS headers like web browsers do.
```typescript
import { OpenPanel } from '@openpanel/react-native';
export const op = new OpenPanel({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
});
```
Put this in a shared file like `lib/op.ts` so you can import it from anywhere in your app. The SDK automatically sets up listeners for app state changes and configures default properties like version and build number.
## Track screen views [#screenviews]
Screen view tracking requires hooking into your navigation library. The approach differs slightly depending on whether you use Expo Router or React Navigation.
If you're using Expo Router, add the tracking call to your root layout component. The `usePathname` hook gives you the current route, and `useSegments` provides the route segments which can be useful for grouping dynamic routes together.
```typescript
import { usePathname, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { op } from '@/lib/op';
export default function RootLayout() {
const pathname = usePathname();
const segments = useSegments();
useEffect(() => {
op.screenView(pathname, {
segments: segments.join('/'),
});
}, [pathname, segments]);
return (
// Your layout JSX
);
}
```
For React Navigation, track screen changes using the navigation container's state change callbacks. Create a navigation ref and pass handlers to `onReady` and `onStateChange`.
```tsx
import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native';
import { op } from '@/lib/op';
const navigationRef = createNavigationContainerRef();
export function App() {
const handleNavigationStateChange = () => {
const current = navigationRef.getCurrentRoute();
if (current) {
op.screenView(current.name, {
params: current.params,
});
}
};
return (
<NavigationContainer
ref={navigationRef}
onReady={handleNavigationStateChange}
onStateChange={handleNavigationStateChange}
>
{/* Your navigators */}
</NavigationContainer>
);
}
```
The `onReady` callback fires on initial load, and `onStateChange` fires on subsequent navigations. This ensures you capture every screen view including the first one.
## Track custom events [#events]
Beyond screen views, you'll want to track specific interactions that matter to your business. Call `op.track` with an event name and optional properties wherever you need to record user actions.
```tsx
import { op } from '@/lib/op';
function SignupButton() {
const handlePress = () => {
op.track('button_clicked', {
button_name: 'signup',
screen: 'home',
});
// Continue with signup logic
};
return <Button onPress={handlePress} title="Sign Up" />;
}
```
Keep event names consistent across your codebase. Using snake_case and a verb-noun pattern like `button_clicked` or `form_submitted` makes your analytics easier to query later.
You can also set global properties that attach to every event. This is useful for metadata like app version or user plan that you want on all events without passing them manually each time.
```tsx
op.setGlobalProperties({
app_version: '1.0.0',
platform: Platform.OS,
});
```
## Identify users [#identify]
When a user logs in, associate their activity with a profile so you can track their behavior across sessions and devices. Call `op.identify` with the user's ID and any profile properties you want to store.
```tsx
import { useEffect } from 'react';
import { op } from '@/lib/op';
function UserProfile({ user }) {
useEffect(() => {
if (user) {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
},
});
}
}, [user]);
return <Text>Welcome, {user?.firstName}!</Text>;
}
```
When the user logs out, call `op.clear()` to reset the identity. This ensures subsequent events aren't incorrectly attributed to the previous user.
```tsx
function handleLogout() {
op.clear();
// Continue with logout logic
}
```
## Verify your setup [#verify]
Run your app and navigate between a few screens to trigger screen view events. Interact with any buttons or forms you've added custom tracking to. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view. You should see events appearing within seconds.
If events aren't showing up, open React Native Debugger or your browser's developer tools (if using Expo web) and check the console for errors. The most common issue is an incorrect Client ID or Client Secret. Make sure both values match what's shown in your OpenPanel dashboard, and remember that React Native requires the client secret whereas web SDKs do not.
## Next steps
The [React Native SDK reference](/docs/sdks/react-native) covers additional configuration options. If you're also building for web, the [JavaScript SDK](/docs/sdks/javascript) shares the same tracking API so your event naming can stay consistent across platforms.
If you're building native iOS or Android apps without React Native, check out the [Swift analytics guide](/guides/swift-analytics) for iOS apps or the [Kotlin analytics guide](/guides/kotlin-analytics) for Android apps.
<Faqs>
<FaqItem question="Why does React Native require a client secret?">
React Native apps can't use CORS headers for authentication like web browsers do. The client secret provides server-side authentication to ensure your events come from a legitimate source. Keep it bundled in your app binary rather than fetching it from an API.
</FaqItem>
<FaqItem question="Does OpenPanel work with Expo?">
Yes. The SDK uses Expo packages for version information and user-agent data, but these work in bare React Native projects too. No special configuration is needed for Expo-managed workflows.
</FaqItem>
<FaqItem question="What happens when the device is offline?">
OpenPanel queues events locally and sends them when the app comes back online. Events won't be lost if the user temporarily loses connectivity.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with data minimization and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns. Read more about [cookieless analytics](/articles/cookieless-analytics).
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,387 @@
---
title: "How to add analytics to Remix"
description: "Add privacy-first analytics to your Remix application with OpenPanel's Web SDK. Track page views, custom events, and user behavior."
difficulty: beginner
timeToComplete: 8
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Create an OpenPanel instance"
anchor: "setup"
- name: "Track page views"
anchor: "pageviews"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
Adding analytics to your Remix application helps you understand how users interact with your app. OpenPanel's Web SDK works seamlessly with Remix's client-side navigation, providing automatic page view tracking, custom events, and user identification.
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It delivers powerful insights while respecting user privacy through cookieless tracking by default.
## Prerequisites
- A Remix project
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the SDK [#install]
The OpenPanel Web SDK is a lightweight package that works in any JavaScript environment. Install it using npm, and pnpm or yarn work the same way.
```bash
npm install @openpanel/web
```
## Create an OpenPanel instance [#setup]
Create a dedicated file for your OpenPanel instance. Since this runs in the browser, place it in your app directory and ensure it only executes on the client.
```ts title="app/lib/op.client.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
The `.client.ts` suffix tells Remix this module should only run in the browser. The `trackScreenViews` option automatically tracks page views when the URL changes, which works with Remix's client-side navigation. The `trackAttributes` option enables declarative tracking using `data-track` attributes.
### Using environment variables
For production applications, pass your Client ID from the server to the client using Remix's loader pattern.
```ts title="app/root.tsx"
import { json } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node';
export async function loader({ request }: LoaderFunctionArgs) {
return json({
ENV: {
OPENPANEL_CLIENT_ID: process.env.OPENPANEL_CLIENT_ID,
},
});
}
```
Then initialize OpenPanel with the environment variable in a client component.
### Initialize in root.tsx
Import and initialize OpenPanel in your root component using a `useEffect` hook to ensure it only runs on the client.
```tsx title="app/root.tsx"
import { useEffect } from 'react';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from '@remix-run/react';
export default function App() {
const { ENV } = useLoaderData<typeof loader>();
useEffect(() => {
// Dynamic import ensures this only runs on the client
import('./lib/op.client').then(({ op }) => {
// OpenPanel is now initialized and tracking
});
}, []);
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
```
## Track page views [#pageviews]
With `trackScreenViews: true`, OpenPanel automatically tracks page views when the browser's URL changes. This works with Remix's client-side navigation using `<Link>` components.
If you need more control over page view tracking or want to include additional route metadata, you can create a component that uses Remix's `useLocation` hook.
```tsx title="app/components/PageTracker.tsx"
import { useEffect } from 'react';
import { useLocation } from '@remix-run/react';
export function PageTracker() {
const location = useLocation();
useEffect(() => {
import('../lib/op.client').then(({ op }) => {
op.track('screen_view', {
path: location.pathname,
search: location.search,
});
});
}, [location.pathname, location.search]);
return null;
}
```
Add this component to your root layout. If you use this approach, set `trackScreenViews: false` in your OpenPanel configuration to avoid duplicate tracking.
## Track custom events [#events]
Import the OpenPanel instance in your components to track events. Since the SDK only works in the browser, use dynamic imports or ensure your tracking code runs in `useEffect` hooks.
```tsx title="app/components/SignupButton.tsx"
export function SignupButton() {
const handleClick = () => {
import('../lib/op.client').then(({ op }) => {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
});
};
return (
<button type="button" onClick={handleClick}>
Sign Up
</button>
);
}
```
### Create a tracking hook
For cleaner code, create a custom hook that handles the dynamic import.
```tsx title="app/hooks/useOpenPanel.ts"
import { useCallback } from 'react';
import type { OpenPanel } from '@openpanel/web';
type TrackFn = OpenPanel['track'];
type IdentifyFn = OpenPanel['identify'];
export function useTrack() {
return useCallback<TrackFn>((name, properties) => {
import('../lib/op.client').then(({ op }) => {
op.track(name, properties);
});
}, []);
}
export function useIdentify() {
return useCallback<IdentifyFn>((payload) => {
import('../lib/op.client').then(({ op }) => {
op.identify(payload);
});
}, []);
}
```
Now your components become cleaner.
```tsx title="app/components/SignupButton.tsx"
import { useTrack } from '../hooks/useOpenPanel';
export function SignupButton() {
const track = useTrack();
const handleClick = () => {
track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
};
return (
<button type="button" onClick={handleClick}>
Sign Up
</button>
);
}
```
### Track form submissions
Remix encourages using form actions for data mutations. You can track form submissions in your action handlers or on the client.
```tsx title="app/routes/contact.tsx"
import { Form } from '@remix-run/react';
import { useTrack } from '../hooks/useOpenPanel';
export default function Contact() {
const track = useTrack();
const handleSubmit = () => {
track('form_submitted', {
form_name: 'contact',
form_location: 'contact-page',
});
};
return (
<Form method="post" onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Your email" required />
<button type="submit">Submit</button>
</Form>
);
}
```
### Use data attributes for declarative tracking
The Web SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
```tsx
<button
data-track="button_clicked"
data-track-button_name="signup"
data-track-button_location="hero"
>
Sign Up
</button>
```
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
## Identify users [#identify]
Once a user logs in, call `identify` to associate their activity with a profile. In Remix, you typically have user data available from a loader.
```tsx title="app/routes/dashboard.tsx"
import { useEffect } from 'react';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { getUser } from '../lib/auth.server';
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
return json({ user });
}
export default function Dashboard() {
const { user } = useLoaderData<typeof loader>();
useEffect(() => {
if (user) {
import('../lib/op.client').then(({ op }) => {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
},
});
});
}
}, [user]);
return <div>Welcome, {user?.firstName}!</div>;
}
```
### Clear user data on logout
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
```tsx title="app/components/LogoutButton.tsx"
import { Form } from '@remix-run/react';
export function LogoutButton() {
const handleClick = () => {
import('../lib/op.client').then(({ op }) => {
op.clear();
});
};
return (
<Form method="post" action="/logout">
<button type="submit" onClick={handleClick}>
Logout
</button>
</Form>
);
}
```
## Server-side tracking [#server-side]
For tracking events in loaders, actions, or API routes, use the `@openpanel/sdk` package instead of the web SDK. Server-side tracking requires a client secret.
```ts title="app/lib/op.server.ts"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
});
```
```ts title="app/routes/api.webhook.ts"
import type { ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { op } from '../lib/op.server';
export async function action({ request }: ActionFunctionArgs) {
const payload = await request.json();
op.track('webhook_received', {
source: payload.source,
event_type: payload.type,
});
return json({ success: true });
}
```
## Verify your setup [#verify]
Open your Remix app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing.
If events aren't appearing, check the browser console for errors. Verify your Client ID is correct and ensure ad blockers aren't blocking requests to the OpenPanel API. The Network tab in your browser's developer tools can help you confirm that requests are being sent.
## Next steps
The Web SDK has additional features like property incrementing and event filtering. Read the full [Web SDK documentation](/docs/sdks/web) for the complete API reference.
For comprehensive server-side tracking, see the [Node.js analytics guide](/guides/nodejs-analytics) which covers the `@openpanel/sdk` package in detail.
<Faqs>
<FaqItem question="Does OpenPanel work with Remix's SSR?">
Yes. OpenPanel's client-side SDK tracks events in the browser after hydration. For server-side events in loaders and actions, use the `@openpanel/sdk` package with your client secret.
</FaqItem>
<FaqItem question="Why do I need a client secret for server-side tracking?">
Server-side tracking requires authentication since we can't use CORS headers. The client secret ensures your events are properly authenticated and prevents unauthorized tracking.
</FaqItem>
<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>
</Faqs>

View File

@@ -0,0 +1,263 @@
---
title: "How to Add Analytics to Swift Apps"
description: "Add privacy-first analytics to your iOS, macOS, tvOS, and watchOS apps with OpenPanel's Swift SDK."
difficulty: beginner
timeToComplete: 10
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Add Swift package"
anchor: "install"
- name: "Initialize OpenPanel"
anchor: "setup"
- name: "Track events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Track screen views"
anchor: "screenviews"
- name: "Verify your setup"
anchor: "verify"
---
## Introduction
Understanding how users interact with your native Apple apps requires solid analytics. OpenPanel's Swift SDK gives you event tracking, user identification, and screen view analytics across iOS, macOS, tvOS, and watchOS platforms.
Since native apps can't rely on CORS headers for authentication like web apps do, the Swift SDK uses a client secret for secure server-side authentication. This makes it suitable for production apps where you need reliable, privacy-respecting analytics. OpenPanel is an open-source alternative to Mixpanel and Amplitude that you can self-host for complete data ownership.
## Prerequisites
- Xcode project set up (iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+)
- OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID and Client Secret from the OpenPanel dashboard
> **Important:** Native app tracking requires a `clientSecret` for authentication.
## Step 1: Add Swift package
The OpenPanel Swift SDK is distributed through Swift Package Manager. Open your project in Xcode, then go to File and select Add Packages. Enter the repository URL and click Add Package.
```
https://github.com/Openpanel-dev/swift-sdk
```
If you're working with a Package.swift file directly, add OpenPanel as a dependency instead.
```swift
dependencies: [
.package(url: "https://github.com/Openpanel-dev/swift-sdk")
]
```
## Step 2: Initialize OpenPanel
Before tracking any events, you need to initialize the SDK when your app launches. This should happen early in your app's lifecycle so the SDK is available throughout your application.
For UIKit apps, add the initialization to your AppDelegate's `application(_:didFinishLaunchingWithOptions:)` method.
```swift title="AppDelegate.swift"
import UIKit
import OpenPanel
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
OpenPanel.initialize(options: .init(
clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET"
))
return true
}
}
```
For SwiftUI apps, initialize the SDK in your App struct's initializer.
```swift title="MyApp.swift"
import SwiftUI
import OpenPanel
@main
struct MyApp: App {
init() {
OpenPanel.initialize(options: .init(
clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET"
))
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
### Automatic lifecycle tracking
You can enable automatic lifecycle tracking by setting `automaticTracking: true`. This will track `app_opened` and `app_closed` events for you.
```swift
OpenPanel.initialize(options: .init(
clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET",
automaticTracking: true
))
```
## Step 3: Track events
Once initialized, you can track events anywhere in your app by calling `OpenPanel.track`. Each event has a name and optional properties that provide additional context.
```swift title="SignupButton.swift"
import SwiftUI
import OpenPanel
struct SignupButton: View {
var body: some View {
Button("Sign Up") {
OpenPanel.track(
name: "button_clicked",
properties: [
"button_name": "signup",
"button_location": "hero"
]
)
}
}
}
```
Properties can be any key-value pairs relevant to the event. Common patterns include tracking form submissions, purchases, and feature usage. Keep event names consistent across your app by using snake_case and being descriptive about what action occurred.
### Set global properties
If you have properties that should be sent with every event, set them once using `setGlobalProperties`. This is useful for app version, build number, or device information.
```swift
OpenPanel.setGlobalProperties([
"app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
"platform": UIDevice.current.systemName
])
```
## Step 4: Identify users
When a user signs in, call `identify` to associate their events with their profile. This enables you to track user journeys across sessions and understand individual user behavior.
```swift title="AuthService.swift"
OpenPanel.identify(payload: IdentifyPayload(
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: [
"plan": user.plan,
"signup_date": user.createdAt.ISO8601Format()
]
))
```
The `profileId` should be a unique identifier for the user, typically from your authentication system. Additional properties like `firstName`, `lastName`, and `email` help you recognize users in your dashboard.
### Clear user data on logout
When a user logs out, call `clear` to reset the local state. This ensures subsequent events aren't incorrectly attributed to the previous user.
```swift
func logout() {
OpenPanel.clear()
}
```
### Increment user properties
You can also increment numeric properties on user profiles. This is useful for tracking counts like logins or purchases without needing to fetch and update the current value.
```swift
OpenPanel.increment(payload: IncrementPayload(
profileId: user.id,
property: "login_count"
))
```
## Step 5: Track screen views
Tracking screen views helps you understand how users navigate through your app. In SwiftUI, use the `onAppear` modifier to track when a view becomes visible.
```swift title="HomeView.swift"
struct HomeView: View {
var body: some View {
VStack {
Text("Home")
}
.onAppear {
OpenPanel.track(
name: "screen_view",
properties: ["screen_name": "HomeScreen"]
)
}
}
}
```
In UIKit, override `viewDidAppear` in your view controllers.
```swift title="HomeViewController.swift"
class HomeViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
OpenPanel.track(
name: "screen_view",
properties: [
"screen_name": "HomeScreen",
"screen_class": String(describing: type(of: self))
]
)
}
}
```
The OpenPanel SDK is designed to be thread-safe. You can call its methods from any thread without additional synchronization.
## Verify your setup
Run your app in the simulator or on a physical device and perform some actions. Navigate between screens and tap a few buttons to generate events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the real-time view.
**Not seeing events?**
- Check the Xcode console for any error messages
- Verify that your Client ID and Client Secret are correct
- Confirm that you included the `clientSecret` parameter (required for native apps)
- Test with a stable network connection first
## Next steps
The [Swift SDK reference](/docs/sdks/swift) covers additional configuration options like event filtering and disabling tracking. If you're building a cross-platform mobile app, the [React Native analytics guide](/guides/react-native-analytics) shows how to set up OpenPanel in that environment.
## FAQ
### Why do I need a clientSecret for native apps?
Native apps can't use CORS headers for authentication like web applications can. The clientSecret provides secure server-side authentication for your events, ensuring they're properly validated before being recorded.
### Does OpenPanel work with both SwiftUI and UIKit?
Yes. OpenPanel works with both UIKit and SwiftUI. Use the `.onAppear` modifier in SwiftUI views or lifecycle methods like `viewDidAppear` in UIKit view controllers to track screen views and other events.
### Can I track events when the user is offline?
OpenPanel queues events locally and sends them when network connectivity is restored. Events won't be lost if the user temporarily goes offline.
### Is OpenPanel GDPR compliant?
Yes. OpenPanel is designed for GDPR compliance with data minimization and support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely. See the [cookieless analytics article](/articles/cookieless-analytics) for more details.

View File

@@ -0,0 +1,226 @@
---
title: "How to track custom events with OpenPanel"
description: "Learn how to track custom events like button clicks, form submissions, and user interactions in OpenPanel."
difficulty: beginner
timeToComplete: 5
date: 2025-12-15
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Understand event structure"
anchor: "event-structure"
- name: "Track simple events"
anchor: "track-simple-events"
- name: "Track events with properties"
anchor: "track-events-with-properties"
- name: "Use data attributes"
anchor: "data-attributes"
- name: "Verify your setup"
anchor: "verify"
---
# How to track custom events with OpenPanel
Custom events are the foundation of product analytics. They let you track specific user actions like button clicks, form submissions, video plays, and purchases. This guide walks you through tracking custom events in OpenPanel across different platforms.
OpenPanel provides a consistent API for event tracking across all SDKs. Once you understand the pattern, you can apply it to any integration.
## Prerequisites
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- OpenPanel SDK installed in your project
- Your Client ID from the dashboard
## Understand event structure [#event-structure]
Every event in OpenPanel consists of two parts: a name and optional properties. The name describes what happened, while properties add context about how, where, and when it happened.
A well-named event reads like a sentence in past tense. Instead of naming an event "click" or "button", use descriptive names like `signup_button_clicked` or `purchase_completed`. This makes your analytics dashboard immediately understandable.
For property names, use snake_case and keep them consistent across your application. If you track `button_name` on one event, use the same property name on similar events rather than switching to `buttonName` or `name`.
## Track simple events [#track-simple-events]
The simplest event is just a name with no properties. This works well for actions where the context is obvious, like clicking a specific button that only appears in one place.
If you're using the Web SDK with npm, import your OpenPanel instance and call the track method.
```typescript
import { op } from './op';
op.track('signup_button_clicked');
```
For the script tag integration, use the global `window.op` function with 'track' as the first argument.
```html
<button onclick="window.op('track', 'signup_button_clicked')">
Sign Up
</button>
```
If you're tracking events server-side with the JavaScript SDK, the pattern is identical.
```typescript
import { op } from './op';
op.track('order_placed');
```
The track method queues events and sends them asynchronously, so it won't block your application logic.
## Track events with properties [#track-events-with-properties]
Properties transform raw events into actionable data. Instead of knowing that someone clicked a button, you can know which button, where it was located, and what the user was doing at the time.
Pass properties as the second argument to the track method. Include any context that will help you segment and analyze the data later.
```typescript
import { op } from './op';
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero_section',
page: 'homepage',
});
```
With the script tag, pass the properties object as the third argument.
```html
<button onclick="window.op('track', 'button_clicked', {button_name: 'signup', button_location: 'hero_section'})">
Sign Up
</button>
```
Think carefully about which properties to include. Track data that helps you answer questions about user behavior, like understanding which signup button performs best or which page drives the most conversions.
Here's an example of tracking a purchase with meaningful properties.
```typescript
op.track('purchase_completed', {
product_id: 'prod_123',
product_name: 'Premium Plan',
amount: 99.99,
currency: 'USD',
payment_method: 'credit_card',
previous_plan: 'free',
});
```
Never include sensitive data in event properties. Passwords, credit card numbers, and personally identifiable information should never appear in your analytics.
## Use data attributes [#data-attributes]
For HTML elements, OpenPanel supports declarative tracking with `data-track` attributes. This approach works well when you want to add tracking without writing JavaScript handlers.
Add `data-track` with the event name, then use additional `data-*` attributes for properties.
```html
<button
data-track="button_clicked"
data-button-name="signup"
data-button-location="hero_section"
>
Sign Up
</button>
```
The SDK automatically converts kebab-case attribute names to snake_case properties. So `data-button-name` becomes `button_name` in your event data.
For complex or nested properties, use `data-track-properties` with a JSON string.
```html
<button
data-track="feature_used"
data-track-properties='{"feature_name": "export", "export_format": "csv", "row_count": 500}'
>
Export Data
</button>
```
Data attributes require the `trackAttributes: true` option in your SDK initialization. If you're not seeing events from data attributes, check that this option is enabled.
## Common event patterns
Form submissions benefit from tracking both the submission and key context about what was submitted.
```typescript
function handleSubmit(event) {
event.preventDefault();
op.track('form_submitted', {
form_name: 'contact',
form_location: 'footer',
fields_completed: 3,
});
// Submit the form
}
```
For video interactions, track play, pause, and completion events with timing information.
```typescript
function handleVideoPlay(video) {
op.track('video_played', {
video_id: video.id,
video_title: video.title,
video_duration: video.duration,
});
}
function handleVideoComplete(video) {
op.track('video_completed', {
video_id: video.id,
watch_time: video.currentTime,
});
}
```
Search events should include the query and results count to help you understand what users are looking for.
```typescript
function handleSearch(query, results) {
op.track('search_performed', {
query: query,
query_length: query.length,
results_count: results.length,
has_results: results.length > 0,
});
}
```
## Verify your setup [#verify]
After implementing event tracking, open your OpenPanel dashboard and navigate to the Real-time view. Trigger the events in your application and confirm they appear within a few seconds.
If events aren't showing up, check that your Client ID is correct and that the SDK initialized without errors. Browser developer tools can help you verify that tracking requests are being sent. Look for network requests to `openpanel.dev` or your self-hosted endpoint.
## Next steps
Now that you're tracking custom events, you can identify users to connect events to specific people. For analyzing your event data, the [funnel analysis guide](/articles/how-to-create-a-funnel) shows you how to measure conversion rates across multi-step flows. You can also explore the [SDK documentation](/docs/sdks/web) for advanced features like global properties and event filtering.
For framework-specific examples and setup instructions, check out:
- [Next.js analytics guide](/guides/nextjs-analytics) for Next.js applications
- [React analytics guide](/guides/react-analytics) for React applications
- [Node.js analytics guide](/guides/nodejs-analytics) for server-side tracking
- [Python analytics guide](/guides/python-analytics) for Python applications
<Faqs>
<FaqItem question="How many events can I track?">
OpenPanel doesn't limit the number of events you can track. Focus on tracking meaningful events that help you understand user behavior and make product decisions.
</FaqItem>
<FaqItem question="Should I track every click?">
No. Track events that help you make decisions. Tracking every click creates noise and makes it harder to find insights. Focus on actions that indicate user intent or progress toward a goal.
</FaqItem>
<FaqItem question="Can I track events server-side?">
Yes. The [Node.js SDK](/docs/sdks/javascript) supports server-side tracking with the same API. Server-side tracking is useful for events that happen outside the browser, like webhook callbacks or background jobs.
</FaqItem>
<FaqItem question="Does OpenPanel use cookies for event tracking?">
No. OpenPanel uses cookieless tracking by default, so you don't need cookie consent banners for basic analytics under most privacy regulations.
</FaqItem>
</Faqs>

View File

@@ -0,0 +1,348 @@
---
title: "How to add analytics to Vue"
description: "Add privacy-first analytics to your Vue application with OpenPanel's Web SDK. Track page views, custom events, and user behavior."
difficulty: beginner
timeToComplete: 8
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"
- name: "Create an OpenPanel instance"
anchor: "setup"
- name: "Track page views"
anchor: "pageviews"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Verify your setup"
anchor: "verify"
---
Adding analytics to your Vue application helps you understand how users interact with your app. OpenPanel's Web SDK integrates smoothly with Vue 3 and Vue Router, providing automatic page view tracking, custom events, and user identification without requiring Vue-specific bindings.
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It delivers powerful insights while respecting user privacy through cookieless tracking by default.
## Prerequisites
- A Vue 3 project (Vite or Vue CLI)
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the SDK [#install]
The OpenPanel Web SDK is a lightweight package that works in any JavaScript environment, including Vue. Install it using npm, and pnpm or yarn work the same way.
```bash
npm install @openpanel/web
```
## Create an OpenPanel instance [#setup]
Create a dedicated file for your OpenPanel instance. This centralizes your analytics configuration and makes the instance easy to import throughout your application.
```ts title="src/lib/op.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
The `trackScreenViews` option automatically tracks page views when the URL changes. This works with Vue Router's client-side navigation. The `trackAttributes` option enables declarative tracking using `data-track` attributes on HTML elements.
### Using environment variables
For production applications, store your Client ID in environment variables.
```ts title="src/lib/op.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
```bash title=".env"
VITE_OPENPANEL_CLIENT_ID=your-client-id
```
### Initialize on app load
Import the OpenPanel instance in your app's entry point to ensure it initializes when your application loads. This is all you need to start tracking page views automatically.
```ts title="src/main.ts"
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './lib/op'; // Initialize OpenPanel
const app = createApp(App);
app.use(router);
app.mount('#app');
```
### Make OpenPanel available globally (optional)
If you prefer accessing OpenPanel through the Vue instance rather than importing it in each component, you can add it as a global property.
```ts title="src/main.ts"
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { op } from './lib/op';
const app = createApp(App);
app.config.globalProperties.$op = op;
app.use(router);
app.mount('#app');
```
This makes `this.$op` available in Options API components and allows you to inject it in Composition API components.
## Track page views [#pageviews]
With `trackScreenViews: true`, OpenPanel automatically tracks page views when the browser's URL changes. This works out of the box with Vue Router.
If you need to track page views manually or want to include additional route metadata, you can use a Vue Router navigation guard.
```ts title="src/router/index.ts"
import { createRouter, createWebHistory } from 'vue-router';
import { op } from '../lib/op';
import routes from './routes';
const router = createRouter({
history: createWebHistory(),
routes,
});
router.afterEach((to) => {
op.track('screen_view', {
path: to.path,
name: String(to.name),
});
});
export default router;
```
If you use this approach, set `trackScreenViews: false` in your OpenPanel configuration to avoid duplicate tracking.
## Track custom events [#events]
Import the OpenPanel instance in your components to track events. The `track` method accepts an event name and an optional properties object.
### Using Composition API
```vue title="src/components/SignupButton.vue"
<template>
<button type="button" @click="handleClick">Sign Up</button>
</template>
<script setup lang="ts">
import { op } from '../lib/op';
function handleClick() {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
}
</script>
```
### Using Options API
```vue title="src/components/SignupButton.vue"
<template>
<button type="button" @click="handleClick">Sign Up</button>
</template>
<script>
import { op } from '../lib/op';
export default {
methods: {
handleClick() {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
},
},
};
</script>
```
### Track form submissions
Form tracking helps you understand conversion rates and identify where users drop off.
```vue title="src/components/ContactForm.vue"
<template>
<form @submit.prevent="handleSubmit">
<input
v-model="email"
type="email"
placeholder="Enter your email"
/>
<button type="submit">Submit</button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { op } from '../lib/op';
const email = ref('');
function handleSubmit() {
op.track('form_submitted', {
form_name: 'contact',
form_location: 'homepage',
});
// Your form submission logic
}
</script>
```
### Use data attributes for declarative tracking
The Web SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
```vue
<template>
<button
data-track="button_clicked"
data-track-button_name="signup"
data-track-button_location="hero"
>
Sign Up
</button>
</template>
```
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
## Identify users [#identify]
Once a user logs in or provides identifying information, call `identify` to associate their activity with a profile. This enables user-level analytics and cohort analysis.
```vue title="src/components/UserProfile.vue"
<template>
<div>Welcome, {{ user?.firstName }}!</div>
</template>
<script setup lang="ts">
import { watch } from 'vue';
import { op } from '../lib/op';
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
plan: string;
createdAt: string;
}
const props = defineProps<{ user: User | null }>();
watch(
() => props.user,
(user) => {
if (user) {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
}
},
{ immediate: true }
);
</script>
```
### Clear user data on logout
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
```vue title="src/components/LogoutButton.vue"
<template>
<button @click="handleLogout">Logout</button>
</template>
<script setup lang="ts">
import { op } from '../lib/op';
const emit = defineEmits<{ logout: [] }>();
function handleLogout() {
op.clear();
emit('logout');
}
</script>
```
### Set global properties
Properties set with `setGlobalProperties` are included with every event. This is useful for app version tracking, feature flags, or A/B test variants.
```vue title="src/App.vue"
<script setup lang="ts">
import { onMounted } from 'vue';
import { op } from './lib/op';
onMounted(() => {
op.setGlobalProperties({
app_version: '1.0.0',
environment: import.meta.env.MODE,
});
});
</script>
```
## Verify your setup [#verify]
Open your Vue app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing.
If events aren't appearing, check the browser console for errors. Verify your Client ID is correct and ensure ad blockers aren't blocking requests to the OpenPanel API. The Network tab in your browser's developer tools can help you confirm that requests are being sent.
## Next steps
The Web SDK has additional features like property incrementing and event filtering. Read the full [Web SDK documentation](/docs/sdks/web) for the complete API reference.
For Nuxt.js applications, the setup is similar. Initialize OpenPanel in a Nuxt plugin and the automatic page view tracking will work with Nuxt's router.
<Faqs>
<FaqItem question="Does OpenPanel work with Vue Router?">
Yes. OpenPanel automatically tracks page views when the URL changes, which works with Vue Router. Set `trackScreenViews: true` in your configuration. You can also use router navigation guards for manual tracking.
</FaqItem>
<FaqItem question="Can I use OpenPanel with Vue 2?">
Yes. The OpenPanel Web SDK is framework-agnostic and works with Vue 2. Import the instance directly in your components and call its methods.
</FaqItem>
<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>
</Faqs>

View File

@@ -0,0 +1,178 @@
---
title: "How to add analytics to any website"
description: "Add privacy-first analytics to any website in minutes using a simple script tag. Works with WordPress, Webflow, Squarespace, and plain HTML."
difficulty: beginner
timeToComplete: 5
date: 2025-12-14
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Get your Client ID"
anchor: "get-id"
- name: "Add the script tag"
anchor: "script"
- name: "Track custom events"
anchor: "events"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to any website
Adding analytics to your website does not require developer expertise. OpenPanel's script tag works with any website, whether you're using WordPress, Webflow, Squarespace, or plain HTML. In about five minutes, you'll have privacy-first analytics running on your site.
OpenPanel is an open-source analytics platform that works without cookies by default. This means you can track meaningful user behavior while respecting privacy and avoiding cookie consent banners in most cases.
## Prerequisites
- A website on any platform
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Access to your website's HTML header or footer
## Get your Client ID [#get-id]
Start by creating an OpenPanel account at [dashboard.openpanel.dev](https://dashboard.openpanel.dev/onboarding). Once logged in, create a new project and copy your Client ID from the project settings.
Your Client ID will look something like `cl_xxxxxxxxxxxxxxxx`. Keep this handy, as you'll need it in the next step.
## Add the script tag [#script]
The OpenPanel script needs to be added to every page of your website. The best approach depends on your platform, but the core snippet remains the same.
Here's the script tag you'll be adding. Replace `YOUR_CLIENT_ID` with the Client ID you copied earlier.
```html
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
```
The `trackScreenViews` option enables automatic page view tracking, so you don't need to manually track each page. The `trackOutgoingLinks` option captures clicks on external links, which is useful for understanding how users leave your site.
### Platform-specific instructions
For WordPress sites, the easiest approach is to use a plugin like "Insert Headers and Footers" or "Code Snippets". Install the plugin, then paste the script tag into the header section. If you prefer working with code, you can add the script directly to your theme's `header.php` file before the closing `</head>` tag.
WordPress users who want more control can add the script via `functions.php` instead.
```php
function add_openpanel_script() {
?>
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
<?php
}
add_action('wp_head', 'add_openpanel_script');
```
For Webflow, go to your project settings and navigate to Custom Code. Paste the script tag into the Head Code section and publish your site. The script will now load on every page.
Squarespace users can find the code injection settings under Settings > Advanced > Code Injection. Add the script to the Header section and save your changes.
If you're using Google Tag Manager, create a new Custom HTML tag and paste the script tag code. Set the trigger to All Pages and publish your container.
## Track custom events [#events]
Page views are tracked automatically, but you'll likely want to track specific user actions like button clicks, form submissions, or video plays. OpenPanel provides two ways to do this.
The simplest approach uses `data-track` attributes directly in your HTML. When a user clicks an element with this attribute, OpenPanel automatically sends an event.
```html
<button data-track="button_clicked" data-button_name="signup">
Sign Up
</button>
```
Any `data-` attributes on the element (except `data-track` itself) are included as event properties. This is useful when you want to track additional context without writing JavaScript.
For more complex tracking, you can call the `window.op` function directly. This gives you full control over when and what to track.
```html
<script>
document.querySelector('.signup-button').addEventListener('click', function() {
window.op('track', 'button_clicked', {
button_name: 'signup',
button_location: 'hero'
});
});
</script>
```
Form submissions are a common tracking use case. You can track them inline using the `onsubmit` attribute.
```html
<form onsubmit="window.op('track', 'form_submitted', {form_name: 'contact'}); return true;">
<input type="email" name="email" placeholder="Your email">
<button type="submit">Submit</button>
</form>
```
### Identifying users
When users log in or provide their email, you can associate their activity with a profile. This is done using the `identify` method.
```html
<script>
window.op('identify', {
profileId: 'user_123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe'
});
</script>
```
The `profileId` is required and should be a unique identifier from your system. Once identified, all subsequent events are associated with this user, enabling cross-session analysis.
## Verify your setup [#verify]
Open your website in a browser and navigate through a few pages. Click some buttons or links that you've set up tracking for. Then head to your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view.
Events should appear within a few seconds. If you're not seeing any data, open your browser's developer console (F12) and look for JavaScript errors. The most common issues are an incorrect Client ID or ad blockers preventing the script from loading.
Ad blockers can interfere with analytics scripts. If this is a concern for your audience, you can proxy the OpenPanel script through your own domain. The [ad blocker documentation](/docs/adblockers) explains how to set this up.
## Next steps
The script tag covers most tracking needs for traditional websites. For more advanced configuration options, check out the [Script Tag SDK reference](/docs/sdks/script). If you want to understand user journeys better, the article on [how to create a funnel](/articles/how-to-create-a-funnel) walks through setting up conversion funnels.
For sites with backend logic or server-side rendering, you might want to combine client-side tracking with server-side events. The [Node.js guide](/guides/nodejs-analytics) and [Python guide](/guides/python-analytics) cover those use cases.
If you're using a specific framework, check out our framework-specific guides for more advanced setups:
- [Next.js analytics guide](/guides/nextjs-analytics) for Next.js applications
- [React analytics guide](/guides/react-analytics) for React applications
- [Astro analytics guide](/guides/astro-analytics) for Astro sites
- [Vue analytics guide](/guides/vue-analytics) for Vue.js applications
<Faqs>
<FaqItem question="Does OpenPanel use cookies?">
No. OpenPanel uses cookieless tracking by default. This means you typically don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
</FaqItem>
<FaqItem question="Will ad blockers prevent tracking?">
Some ad blockers may block requests to openpanel.dev. You can mitigate this by proxying the script through your own domain or by self-hosting OpenPanel. The documentation covers both approaches.
</FaqItem>
<FaqItem question="Can I use OpenPanel with Google Tag Manager?">
Yes. Add the OpenPanel script as a Custom HTML tag in Google Tag Manager with an All Pages trigger. This lets you manage OpenPanel alongside your other tracking scripts.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and support for data subject rights. Self-hosting eliminates international data transfer concerns entirely. Read more about [cookieless analytics](/articles/cookieless-analytics) for details.
</FaqItem>
</Faqs>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

View File

@@ -36,6 +36,23 @@ const zPage = z.object({
description: z.string(),
});
const zGuide = z.object({
title: z.string().min(1),
description: z.string(),
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
timeToComplete: z.number(), // minutes
date: z.date(),
updated: z.date().optional(),
cover: z.string().default('/content/cover-default.jpg'),
team: z.string().optional(),
steps: z.array(
z.object({
name: z.string(),
anchor: z.string(),
}),
),
});
export const articleCollection = defineCollections({
type: 'doc',
dir: './content/articles',
@@ -60,6 +77,18 @@ export const pageMeta = defineCollections({
schema: zPage,
});
export const guideCollection = defineCollections({
type: 'doc',
dir: './content/guides',
schema: zGuide,
});
export const guideMeta = defineCollections({
type: 'meta',
dir: './content/guides',
schema: zGuide,
});
export default defineConfig({
mdxOptions: {
// MDX options

View File

@@ -0,0 +1,212 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
import { FeatureCardContainer } from '@/components/feature-card';
import { GetStartedButton } from '@/components/get-started-button';
import { GuideCard } from '@/components/guide-card';
import { Logo } from '@/components/logo';
import { SectionHeader } from '@/components/section';
import { Toc } from '@/components/toc';
import { url, getAuthor } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { guideSource } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { ArrowLeftIcon, ClockIcon } 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';
const difficultyColors = {
beginner: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
intermediate:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
advanced: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
const difficultyLabels = {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
};
export async function generateStaticParams() {
const guides = await guideSource.getPages();
return guides.map((guide) => {
// Extract slug from URL (e.g., '/guides/my-guide' -> 'my-guide')
const slug = guide.url.replace(/^\/guides\//, '').replace(/\/$/, '');
return { guideSlug: slug };
});
}
export async function generateMetadata({
params,
}: {
params: Promise<{ guideSlug: string }>;
}): Promise<Metadata> {
const { guideSlug } = await params;
const guide = await guideSource.getPage([guideSlug]);
if (!guide) {
return {
title: 'Guide Not Found',
};
}
return getPageMetadata({
title: guide.data.title,
description: guide.data.description,
url: url(guide.url),
image: getOgImageUrl(guide.url),
});
}
export default async function Page({
params,
}: {
params: Promise<{ guideSlug: string }>;
}) {
const { guideSlug } = await params;
const guide = await guideSource.getPage([guideSlug]);
const Body = guide?.data.body;
const author = getAuthor(guide?.data.team);
const goBackUrl = '/guides';
const relatedGuides = (await guideSource.getPages())
.filter(
(item) =>
item.data.difficulty === guide?.data.difficulty &&
item.url !== guide?.url,
)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.slice(0, 3);
if (!Body) {
return notFound();
}
const slug = guide.url.replace(/^\/guides\//, '').replace(/\/$/, '');
// Create the HowTo JSON-LD schema
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: guide?.data.title,
description: guide?.data.description,
totalTime: `PT${guide?.data.timeToComplete}M`,
step: guide?.data.steps.map((step, i) => ({
'@type': 'HowToStep',
position: i + 1,
name: step.name,
url: url(`/guides/${slug}#${step.anchor}`),
})),
};
return (
<div>
<HeroContainer>
<div className="col">
<Link
href={goBackUrl}
className="flex items-center gap-2 mb-4 text-muted-foreground"
>
<ArrowLeftIcon className="w-4 h-4" />
<span>Back to all guides</span>
</Link>
<SectionHeader
as="h1"
title={guide?.data.title}
description={guide?.data.description}
/>
<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 flex-1">
<p className="font-medium">{author?.name || 'OpenPanel Team'}</p>
<div className="row gap-4 items-center">
<p className="text-muted-foreground text-sm">
{guide?.data.date.toLocaleDateString()}
</p>
{guide?.data.updated && (
<p className="text-muted-foreground text-sm italic">
Updated on {guide?.data.updated.toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="row gap-3 items-center">
<span
className={`font-mono text-xs px-3 py-1 rounded ${difficultyColors[guide?.data.difficulty || 'beginner']}`}
>
{difficultyLabels[guide?.data.difficulty || 'beginner']}
</span>
<div className="row gap-1 items-center text-muted-foreground text-sm">
<ClockIcon className="w-4 h-4" />
<span>{guide?.data.timeToComplete} min</span>
</div>
</div>
</div>
</div>
</HeroContainer>
<Script
strategy="beforeInteractive"
id="guide-howto-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="container max-w-5xl col">
<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 components={getMDXComponents()} />
</div>
</div>
<aside className="pl-12 pb-12 gap-8 col">
<Toc toc={guide?.data.toc} />
<FeatureCardContainer className="gap-2">
<span className="text-lg font-semibold">Try OpenPanel</span>
<p className="text-muted-foreground text-sm mb-4">
Give it a spin for free. No credit card required.
</p>
<GetStartedButton />
</FeatureCardContainer>
</aside>
</div>
{relatedGuides.length > 0 && (
<div className="my-16">
<h3 className="text-2xl font-bold mb-8">Related guides</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{relatedGuides.map((item) => (
<GuideCard
key={item.url}
url={item.url}
title={item.data.title}
difficulty={item.data.difficulty}
timeToComplete={item.data.timeToComplete}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</div>
</div>
)}
</article>
<Testimonials />
<CtaBanner />
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { Testimonials } from '@/app/(home)/_sections/testimonials';
import { GuideCard } from '@/components/guide-card';
import { Section, SectionHeader } from '@/components/section';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { guideSource } from '@/lib/source';
import type { Metadata } from 'next';
import Script from 'next/script';
export const metadata: Metadata = getPageMetadata({
title: 'Implementation Guides',
description:
'Step-by-step tutorials for adding privacy-first analytics to your app with OpenPanel.',
url: url('/guides'),
image: getOgImageUrl('/guides'),
});
export default async function Page() {
const guides = (await guideSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
// Create ItemList schema for SEO
const itemListSchema = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'OpenPanel Implementation Guides',
description: 'Step-by-step tutorials for adding analytics to your app',
itemListElement: guides.map((guide, index) => {
const slug = guide.url.replace(/^\/guides\//, '').replace(/\/$/, '');
return {
'@type': 'ListItem',
position: index + 1,
name: guide.data.title,
url: url(guide.url),
};
}),
};
return (
<div>
<HeroContainer className="-mb-32">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title="Implementation Guides"
description="Step-by-step tutorials for adding privacy-first analytics to your app with OpenPanel."
/>
</HeroContainer>
<Script
strategy="beforeInteractive"
id="guides-itemlist-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListSchema) }}
/>
<Section className="container grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
{guides.map((item) => (
<GuideCard
key={item.url}
url={item.url}
title={item.data.title}
difficulty={item.data.difficulty}
timeToComplete={item.data.timeToComplete}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</Section>
<Testimonials />
<CtaBanner />
</div>
);
}

View File

@@ -0,0 +1,388 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { FaqItem, Faqs } from '@/components/faq';
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import {
BarChartIcon,
CheckIcon,
CodeIcon,
GlobeIcon,
HeartHandshakeIcon,
LinkIcon,
MailIcon,
MessageSquareIcon,
SparklesIcon,
UsersIcon,
ZapIcon,
} from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
export const metadata: Metadata = getPageMetadata({
title: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
url: url('/open-source'),
image: getOgImageUrl('/open-source'),
});
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
url: url('/open-source'),
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntity: {
'@type': 'Offer',
name: 'Free Analytics for Open Source Projects',
description:
'Free analytics service for open source projects up to 2.5M events per month',
price: '0',
priceCurrency: 'USD',
},
};
export default function OpenSourcePage() {
return (
<div>
<Script
id="open-source-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<div className="col center-center flex-1">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title={
<>
Free Analytics for
<br />
Open Source Projects
</>
}
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
/>
<div className="col gap-4 justify-center items-center mt-8">
<Button size="lg" asChild>
<Link href="mailto:oss@openpanel.dev">
Apply for Free Access
<MailIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Up to 2.5M events/month No credit card required
</p>
</div>
</div>
</HeroContainer>
<div className="container">
<div className="col gap-16">
{/* What You Get Section */}
<Section className="my-0">
<SectionHeader
title="What you get"
description="Everything you need to understand your users and grow your open source project."
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<FeatureCard
title="2.5 Million Events/Month"
description="More than enough for most open source projects. Track page views, user actions, and custom events without worrying about limits."
icon={BarChartIcon}
/>
<FeatureCard
title="Full Feature Access"
description="Same powerful capabilities as paid plans. Funnels, retention analysis, custom dashboards, and real-time analytics."
icon={ZapIcon}
/>
<FeatureCard
title="Unlimited Team Members"
description="Invite your entire contributor team. Collaborate with maintainers and core contributors on understanding your project's growth."
icon={UsersIcon}
/>
<FeatureCard
title="Priority Support"
description="Dedicated help for open source maintainers. Get faster responses and priority assistance when you need it."
icon={MessageSquareIcon}
/>
</div>
</Section>
{/* Why We Do This Section */}
<Section className="my-0">
<SectionHeader
title="Why we do this"
description="OpenPanel is built by and for the open source community. We believe in giving back."
/>
<div className="col gap-6 mt-8">
<p className="text-muted-foreground">
We started OpenPanel because we believed analytics tools
shouldn't be complicated or locked behind expensive enterprise
subscriptions. As an open source project ourselves, we
understand the challenges of building and growing a project
without the resources of big corporations.
</p>
<div className="grid md:grid-cols-3 gap-4">
<FeatureCard
title="Built for OSS"
description="OpenPanel is open source. We know what it's like to build in the open."
icon={CodeIcon}
/>
<FeatureCard
title="No Barriers"
description="Analytics shouldn't be a barrier to understanding your users. We're removing that barrier."
icon={HeartHandshakeIcon}
/>
<FeatureCard
title="Giving Back"
description="We're giving back to the projects that inspire us and the community that supports us."
icon={SparklesIcon}
/>
</div>
</div>
</Section>
{/* What We Ask In Return Section */}
<Section className="my-0">
<SectionHeader
title="What we ask in return"
description="We keep it simple. Just a small way to help us grow and support more projects."
/>
<div className="row gap-6 mt-8">
<div className="col gap-6">
<FeatureCard
title="Backlink to OpenPanel"
description="A simple link on your website or README helps others discover OpenPanel. It's a win-win for the community."
icon={LinkIcon}
>
<p className="text-sm text-muted-foreground mt-2">
Example: "Analytics powered by{' '}
<Link
href="https://openpanel.dev"
className="text-primary hover:underline"
>
OpenPanel
</Link>
"
</p>
</FeatureCard>
<FeatureCard
title="Display a Widget"
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
icon={GlobeIcon}
>
<p className="text-sm text-muted-foreground mt-2">
Display real-time visitor counts, page views, or other
metrics on your project's website.
</p>
</FeatureCard>
<p className="text-muted-foreground">
That's it. No complicated requirements, no hidden fees, no
catch. We just want to help open source projects succeed.
</p>
</div>
<div className="text-center text-xs text-muted-foreground">
<iframe
title="Realtime Widget"
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
width="300"
height="400"
className="rounded-xl border mb-2"
/>
Analytics from{' '}
<a className="underline" href="https://openpanel.dev">
OpenPanel.dev
</a>
</div>
</div>
</Section>
{/* Eligibility Criteria Section */}
<Section className="my-0">
<SectionHeader
title="Eligibility criteria"
description="We want to support legitimate open source projects that are making a difference."
/>
<div className="col gap-4 mt-8">
<div className="grid md:grid-cols-2 gap-4">
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">OSI-Approved License</h3>
<p className="text-sm text-muted-foreground">
Your project must use an OSI-approved open source license
(MIT, Apache, GPL, etc.)
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">Public Repository</h3>
<p className="text-sm text-muted-foreground">
Your code must be publicly available on GitHub, GitLab, or
similar platforms
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">Active Development</h3>
<p className="text-sm text-muted-foreground">
Show evidence of active development and a growing
community
</p>
</div>
</div>
<div className="flex gap-3">
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold mb-1">
Non-Commercial Primary Purpose
</h3>
<p className="text-sm text-muted-foreground">
The primary purpose should be non-commercial, though
commercial OSS projects may be considered
</p>
</div>
</div>
</div>
</div>
</Section>
{/* How to Apply Section */}
<Section className="my-0">
<SectionHeader
title="How to apply"
description="Getting started is simple. Just send us an email with a few details about your project."
/>
<div className="col gap-6 mt-8">
<div className="grid md:grid-cols-3 gap-6">
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
1
</div>
<h3 className="font-semibold">Send us an email</h3>
<p className="text-sm text-muted-foreground">
Reach out to{' '}
<Link
href="mailto:oss@openpanel.dev"
className="text-primary hover:underline"
>
oss@openpanel.dev
</Link>{' '}
with your project details
</p>
</div>
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
2
</div>
<h3 className="font-semibold">Include project info</h3>
<p className="text-sm text-muted-foreground">
Share your project URL, license type, and a brief
description of what you're building
</p>
</div>
<div className="col gap-3">
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
3
</div>
<h3 className="font-semibold">We'll review</h3>
<p className="text-sm text-muted-foreground">
We'll evaluate your project and respond within a few
business days
</p>
</div>
</div>
<div className="mt-4">
<Button size="lg" asChild>
<Link href="mailto:oss@openpanel.dev?subject=Open Source Program Application">
Apply Now
<MailIcon className="size-4" />
</Link>
</Button>
</div>
</div>
</Section>
{/* FAQ Section */}
<Section className="my-0">
<SectionHeader
title="Frequently asked questions"
description="Everything you need to know about our open source program."
/>
<div className="mt-8">
<Faqs>
<FaqItem question="What counts as an open-source project?">
We consider any project with an OSI-approved open source
license (MIT, Apache, GPL, BSD, etc.) that is publicly
available and actively maintained. The project should have a
non-commercial primary purpose, though we may consider
commercial open source projects on a case-by-case basis.
</FaqItem>
<FaqItem question="What happens if I exceed 2.5M events per month?">
We understand that successful projects grow. If you
consistently exceed 2.5M events, we'll reach out to discuss
options. We're flexible and want to support your success. In
most cases, we can work out a solution that works for both of
us.
</FaqItem>
<FaqItem question="Can commercial open source projects apply?">
Yes, we consider commercial open source projects on a
case-by-case basis. If your project is open source but has
commercial offerings, please mention this in your application
and we'll evaluate accordingly.
</FaqItem>
<FaqItem question="How long does the free access last?">
As long as your project remains eligible and active, your free
access continues. We review projects periodically to ensure
they still meet our criteria, but we're committed to
supporting projects long-term.
</FaqItem>
<FaqItem question="Do I need to display the widget?">
No, displaying the widget is completely optional. We only
require a backlink to OpenPanel on your website or README. The
widget is just a nice way to showcase your analytics if you
want to.
</FaqItem>
<FaqItem question="What if my project is very small or just starting?">
We welcome projects of all sizes! Whether you're just getting
started or have a large community, if you meet our eligibility
criteria, we'd love to help. Small projects often benefit the
most from understanding their users early on.
</FaqItem>
</Faqs>
</div>
</Section>
<CtaBanner
title="Ready to get free analytics for your open source project?"
description="Join other open source projects using OpenPanel to understand their users and grow their communities. Apply today and get started in minutes."
ctaText="Apply for Free Access"
ctaLink="mailto:oss@openpanel.dev?subject=Open Source Program Application"
/>
</div>
</div>
</div>
);
}

View File

@@ -7,10 +7,9 @@ import Image from 'next/image';
const images = [
{
name: 'Helpy UI',
url: 'https://helpy-ui.com',
logo: '/logos/helpy-ui.png',
className: 'size-12',
name: 'Lucide Animated',
url: 'https://lucide-animated.com',
logo: '/logos/lucide-animated.png',
},
{
name: 'KiddoKitchen',
@@ -67,10 +66,7 @@ export function WhyOpenPanel() {
alt={image.name}
width={64}
height={64}
className={cn(
'size-16 object-contain dark:invert',
image.className,
)}
className={cn('size-16 object-contain dark:invert')}
/>
</a>
</div>

View File

@@ -27,10 +27,6 @@ interface IPInfoResponse {
latitude: number | undefined;
longitude: number | undefined;
};
isp: string | null;
asn: string | null;
organization: string | null;
hostname: string | null;
isLocalhost: boolean;
isPrivate: boolean;
}
@@ -90,84 +86,6 @@ function isPrivateIP(ip: string): boolean {
return false;
}
async function getIPInfo(ip: string): Promise<IPInfo> {
if (!ip || ip === '127.0.0.1' || ip === '::1') {
return {
ip,
location: {
country: undefined,
city: undefined,
region: undefined,
latitude: undefined,
longitude: undefined,
},
isp: null,
asn: null,
organization: null,
hostname: null,
};
}
// Get geolocation
const geo = await getGeoLocation(ip);
// Get ISP/ASN info
let isp: string | null = null;
let asn: string | null = null;
let organization: string | null = null;
if (!isPrivateIP(ip)) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,reverse`,
{
signal: controller.signal,
},
);
clearTimeout(timeout);
if (response.ok) {
const data = await response.json();
if (data.status !== 'fail') {
isp = data.isp || null;
asn = data.as ? `AS${data.as.split(' ')[0]}` : null;
organization = data.org || null;
}
}
} catch {
// Ignore errors
}
}
// Reverse DNS lookup for hostname
let hostname: string | null = null;
try {
const hostnames = await dns.reverse(ip);
hostname = hostnames[0] || null;
} catch {
// Ignore errors
}
return {
ip,
location: {
country: geo.country,
city: geo.city,
region: geo.region,
latitude: geo.latitude,
longitude: geo.longitude,
},
isp,
asn,
organization,
hostname,
};
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const ipParam = searchParams.get('ip');
@@ -209,12 +127,17 @@ export async function GET(request: Request) {
}
try {
const info = await getIPInfo(ipToLookup);
const geo = await fetch('https://api.openpanel.dev/misc/geo', {
headers: request.headers,
})
.then((res) => res.json())
.then((data) => data.selected.geo);
const isLocalhost = ipToLookup === '127.0.0.1' || ipToLookup === '::1';
const isPrivate = isPrivateIP(ipToLookup);
const response: IPInfoResponse = {
...info,
location: geo,
ip: ipToLookup,
isLocalhost,
isPrivate,
};

View File

@@ -12,7 +12,11 @@ import {
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
type PageProps = {
params: Promise<{ slug: string[] }>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -39,9 +43,7 @@ export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();

View File

@@ -1,8 +1,8 @@
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/docs'>) {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions()}>
{children}

View File

@@ -28,7 +28,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: LayoutProps<'/'>) {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"

View File

@@ -1,6 +1,6 @@
import { getAllCompareSlugs, getCompareData } from '@/lib/compare';
import { url as baseUrl } from '@/lib/layout.shared';
import { articleSource, pageSource, source } from '@/lib/source';
import { articleSource, guideSource, pageSource, source } from '@/lib/source';
import { ImageResponse } from 'next/og';
import type { NextRequest } from 'next/server';
@@ -26,6 +26,13 @@ async function getOgData(
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
};
}
case 'open-source': {
return {
title: 'Free analytics for open source projects',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
};
}
case 'pricing': {
return {
title: 'Pricing',
@@ -62,6 +69,20 @@ async function getOgData(
description: data?.seo.description || data?.hero.subheading,
};
}
case 'guides': {
if (segments.length > 1) {
const data = await guideSource.getPage(segments.slice(1));
return {
title: data?.data.title ?? 'Guide Not Found',
description:
data?.data.description || 'Whooops, could not find this guide',
};
}
return {
title: 'Implementation Guides',
description: 'Step-by-step tutorials for adding analytics to your app',
};
}
case 'docs': {
const data = await source.getPage(segments.slice(1));
return {
@@ -70,6 +91,34 @@ async function getOgData(
data?.data.description || 'Whooops, could not find this page',
};
}
case 'tools': {
if (segments.length > 1) {
const tool = segments[1];
switch (tool) {
case 'ip-lookup':
return {
title: 'IP Lookup Tool',
description:
'Find detailed information about any IP address including geolocation, ISP, and network details.',
};
case 'url-checker':
return {
title: 'URL Checker',
description:
'Analyze any website for SEO, social media, technical, and security information. Get comprehensive insights about any URL.',
};
default:
return {
title: 'Tools',
description: 'Free web tools for developers and website owners',
};
}
}
return {
title: 'Tools',
description: 'Free web tools for developers and website owners',
};
}
default: {
const data = await pageSource.getPage(segments);
return {

View File

@@ -1,11 +1,18 @@
import { url } from '@/lib/layout.shared';
import { articleSource, compareSource, pageSource, source } from '@/lib/source';
import {
articleSource,
compareSource,
guideSource,
pageSource,
source,
} from '@/lib/source';
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await articleSource.getPages();
const docs = await source.getPages();
const pages = await pageSource.getPages();
const guides = await guideSource.getPages();
return [
{
url: url('/'),
@@ -49,6 +56,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'yearly' as const,
priority: 0.5,
})),
...guides.map((item) => ({
url: url(item.url),
lastModified: item.data.date,
changeFrequency: 'monthly' as const,
priority: 0.5,
})),
...docs.map((item) => ({
url: url(item.url),
changeFrequency: 'monthly' as const,

View File

@@ -275,49 +275,6 @@ export default function IPLookupPage() {
</div>
)}
{/* Network Information */}
{(result.isp ||
result.asn ||
result.organization ||
result.hostname) && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Network className="size-5" />
Network Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{result.isp && (
<InfoCard
icon={<Building2 className="size-5" />}
label="ISP"
value={result.isp}
/>
)}
{result.asn && (
<InfoCard
icon={<Network className="size-5" />}
label="ASN"
value={result.asn}
/>
)}
{result.organization && (
<InfoCard
icon={<Building2 className="size-5" />}
label="Organization"
value={result.organization}
/>
)}
{result.hostname && (
<InfoCard
icon={<Server className="size-5" />}
label="Hostname"
value={result.hostname}
/>
)}
</div>
</div>
)}
{/* Map Preview */}
{result.location.latitude && result.location.longitude && (
<div>

View File

@@ -25,6 +25,10 @@ export async function Footer() {
{ title: 'About', url: '/about' },
{ title: 'Contact', url: '/contact' },
{ title: 'Become a supporter', url: '/supporter' },
{
title: 'Free analytics for open source projects',
url: '/open-source',
},
]}
/>
</div>
@@ -36,6 +40,7 @@ export async function Footer() {
{ title: 'Pricing', url: '/pricing' },
{ title: 'Documentation', url: '/docs' },
{ title: 'SDKs', url: '/docs/sdks' },
{ title: 'Guides', url: '/guides' },
{ title: 'Articles', url: '/articles' },
{ title: 'Compare', url: '/compare' },
]}

View File

@@ -0,0 +1,67 @@
import Image from 'next/image';
import Link from 'next/link';
const difficultyColors = {
beginner: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
intermediate:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
advanced: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
const difficultyLabels = {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
};
export function GuideCard({
url,
title,
difficulty,
timeToComplete,
cover,
team,
date,
}: {
url: string;
title: string;
difficulty: 'beginner' | 'intermediate' | 'advanced';
timeToComplete: number;
cover: string;
team?: string;
date: Date;
}) {
return (
<Link
href={url}
key={url}
className="border rounded-lg overflow-hidden bg-background-light col hover:scale-105 transition-all duration-300 hover:shadow-lg hover:shadow-background-dark"
>
<Image
src={cover}
alt={title}
width={323}
height={181}
className="w-full"
/>
<span className="p-4 col flex-1">
<div className="flex items-center gap-2 mb-2">
<span
className={`font-mono text-xs px-2 py-1 rounded ${difficultyColors[difficulty]}`}
>
{difficultyLabels[difficulty]}
</span>
<span className="text-xs text-muted-foreground">
{timeToComplete} min
</span>
</div>
<span className="flex-1 mb-6">
<h2 className="text-xl font-semibold">{title}</h2>
</span>
<p className="text-sm text-muted-foreground">
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
</p>
</span>
</Link>
);
}

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import {
articleCollection,
docs,
guideCollection,
pageCollection,
} from 'fumadocs-mdx:collections/server';
import { type InferPageType, loader } from 'fumadocs-core/source';
@@ -29,6 +30,12 @@ export const pageSource = loader({
source: toFumadocsSource(pageCollection, []),
});
export const guideSource = loader({
baseUrl: '/guides',
source: toFumadocsSource(guideCollection, []),
plugins: [lucideIconsPlugin()],
});
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];

View File

@@ -34,7 +34,6 @@ COPY packages/payments/package.json packages/payments/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/sdks/_info/package.json packages/sdks/_info/
COPY patches ./patches
# Copy tracking script to self-hosting dashboard
@@ -93,7 +92,6 @@ COPY --from=build /app/packages/payments/package.json ./packages/payments/
COPY --from=build /app/packages/constants/package.json ./packages/constants/
COPY --from=build /app/packages/validation/package.json ./packages/validation/
COPY --from=build /app/packages/integrations/package.json ./packages/integrations/
COPY --from=build /app/packages/sdks/sdk/package.json ./packages/sdks/sdk/
COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/
COPY --from=build /app/patches ./patches
@@ -132,7 +130,6 @@ COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info
COPY --from=build /app/tooling/typescript ./tooling/typescript

View File

@@ -10,7 +10,6 @@
"cf-typegen": "wrangler types",
"build": "pnpm with-env vite build",
"serve": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
@@ -25,7 +24,8 @@
"@faker-js/faker": "^9.6.0",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@number-flow/react": "0.3.5",
"@nivo/sankey": "^0.99.0",
"@number-flow/react": "0.5.10",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/integrations": "workspace:^",
@@ -149,7 +149,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "^1.13.12",
"@cloudflare/vite-plugin": "1.20.3",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
@@ -170,6 +170,6 @@
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "^4.42.2"
"wrangler": "4.59.1"
}
}
}

View File

@@ -1,20 +1,6 @@
import type { NumberFlowProps } from '@number-flow/react';
import { useEffect, useState } from 'react';
import ReactAnimatedNumber from '@number-flow/react';
// NumberFlow is breaking ssr and forces loaders to fetch twice
export function AnimatedNumber(props: NumberFlowProps) {
const [Component, setComponent] =
useState<React.ComponentType<NumberFlowProps> | null>(null);
useEffect(() => {
import('@number-flow/react').then(({ default: NumberFlow }) => {
setComponent(NumberFlow);
});
}, []);
if (!Component) {
return <>{props.value}</>;
}
return <Component {...props} />;
return <ReactAnimatedNumber {...props} />;
}

View File

@@ -4,11 +4,17 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { LogoSquare } from '../logo';
import { PublicPageCard } from '../public-page-card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({ shareId }: { shareId: string }) {
export function ShareEnterPassword({
shareId,
shareType = 'overview',
}: {
shareId: string;
shareType?: 'overview' | 'dashboard' | 'report';
}) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
@@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
defaultValues: {
password: '',
shareId,
shareType,
},
});
@@ -32,46 +39,31 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
mutation.mutate({
password: data.password,
shareId,
shareType,
});
});
const typeLabel =
shareType === 'dashboard'
? 'Dashboard'
: shareType === 'report'
? 'Report'
: 'Overview';
return (
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</div>
<div className="p-6 text-sm max-w-sm col gap-0.5">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
</p>
<p>
The best web and product analytics tool out there (our honest
opinion).
</p>
<p>
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
</div>
<PublicPageCard
title={`${typeLabel} is locked`}
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
>
<form onSubmit={onSubmit} className="col gap-4">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</PublicPageCard>
);
}

View File

@@ -5,9 +5,15 @@ import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export const ChartTooltipContainer = ({
children,
}: { children: React.ReactNode }) => {
className,
}: { children: React.ReactNode; className?: string }) => {
return (
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
<div
className={cn(
'min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
className,
)}
>
{children}
</div>
);

View File

@@ -1,6 +1,7 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zChartInputAI } from '@openpanel/validation';
import { zReport } from '@openpanel/validation';
import { z } from 'zod';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
@@ -77,7 +78,10 @@ export const ChatMessage = memo(
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zChartInputAI.safeParse(result.report);
const report = zReport.extend({
startDate: z.string(),
endDate: z.string(),
}).safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>

View File

@@ -1,6 +1,6 @@
import { pushModal } from '@/modals';
import type {
IChartInputAi,
IReport,
IChartRange,
IChartType,
IInterval,
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IChartInputAi; lazy: boolean }) {
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);

View File

@@ -0,0 +1,65 @@
import { cn } from '@/utils/cn';
import { type VariantProps, cva } from 'class-variance-authority';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
const deltaChipVariants = cva(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
{
variants: {
variant: {
inc: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
dec: 'bg-red-500/10 text-red-600 dark:text-red-400',
default: 'bg-muted text-muted-foreground',
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
},
);
type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
children: React.ReactNode;
inverted?: boolean;
};
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
sm: 12,
md: 16,
lg: 20,
};
const getVariant = (variant: DeltaChipProps['variant'], inverted?: boolean) => {
if (inverted) {
return variant === 'inc' ? 'dec' : variant === 'dec' ? 'inc' : variant;
}
return variant;
};
export function DeltaChip({
variant,
size,
inverted,
children,
}: DeltaChipProps) {
return (
<div
className={cn(
deltaChipVariants({ variant: getVariant(variant, inverted), size }),
)}
>
{variant === 'inc' ? (
<ArrowUpIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : variant === 'dec' ? (
<ArrowDownIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : null}
<span>{children}</span>
</div>
);
}

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