Compare commits
69 Commits
feature/gu
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcddc6f284 | ||
|
|
286f8e160b | ||
|
|
f8f470adf9 | ||
|
|
e7c2834ea0 | ||
|
|
753d6dce4c | ||
|
|
9e5b482447 | ||
|
|
32ea28b2f6 | ||
|
|
b39d076b32 | ||
|
|
ec5937e55c | ||
|
|
f83fe7a0fc | ||
|
|
6c56efdf37 | ||
|
|
e5be28a49d | ||
|
|
e645c094b2 | ||
|
|
67301d928c | ||
|
|
deb3c3d20c | ||
|
|
6e997e62f1 | ||
|
|
2c5ca8adec | ||
|
|
3e573ae27f | ||
|
|
5b29f7502c | ||
|
|
d32a279949 | ||
|
|
ed6e5cd334 | ||
|
|
cf1bf95388 | ||
|
|
5830277ba9 | ||
|
|
aa13c87e87 | ||
|
|
83c3647f66 | ||
|
|
927613c09d | ||
|
|
24ee6b0b6c | ||
|
|
13d8b92cf3 | ||
|
|
4b2db351c4 | ||
|
|
334adec9f2 | ||
|
|
9a54daae55 | ||
|
|
7cd5f84c58 | ||
|
|
470ddbe8e7 | ||
|
|
c63578b35b | ||
|
|
b5792df69f | ||
|
|
00f2e2937d | ||
|
|
0d1773eb74 | ||
|
|
ed1c57dbb8 | ||
|
|
39251c8598 | ||
|
|
9a4aa51975 | ||
|
|
f008fb58e5 | ||
|
|
cabfb1f3f0 | ||
|
|
4867260ece | ||
|
|
87c919f700 | ||
|
|
3c085e445d | ||
|
|
6d9e3ce8e5 | ||
|
|
f187065d75 | ||
|
|
d5e4518e32 | ||
|
|
1f088d2208 | ||
|
|
3bd1f99d28 | ||
|
|
9a916f3171 | ||
|
|
34cb186ead | ||
|
|
5f38560373 | ||
|
|
1e4f02fb5e | ||
|
|
3158ebfbda | ||
|
|
d7c6e88adc | ||
|
|
3b61b28290 | ||
|
|
8dfeaa870c | ||
|
|
329f76b7ce | ||
|
|
3b74d8ae36 | ||
|
|
a2a53cf9f7 | ||
|
|
4e7dc16619 | ||
|
|
0f9ac4508a | ||
|
|
c46cda12eb | ||
|
|
546ef6673f | ||
|
|
3b2ed3afb1 | ||
|
|
95846f80e5 | ||
|
|
1f5c648afe | ||
|
|
3d8a3e8997 |
1
.github/workflows/docker-build.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- "apps/api/**"
|
||||
- "apps/worker/**"
|
||||
- "apps/public/**"
|
||||
- "apps/start/**"
|
||||
- "packages/**"
|
||||
- "!packages/sdks/**"
|
||||
- "**Dockerfile"
|
||||
|
||||
@@ -98,6 +98,10 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
|
||||
### Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
echo "API_URL=http://localhost:3333" > apps/start/.env
|
||||
|
||||
pnpm dock:up
|
||||
pnpm codegen
|
||||
pnpm migrate:deploy # once to setup the db
|
||||
@@ -110,4 +114,4 @@ You can now access the following:
|
||||
- API: https://api.localhost:3333
|
||||
- Bullboard (queue): http://localhost:9999
|
||||
- `pnpm dock:ch` to access clickhouse terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
|
||||
@@ -38,11 +38,10 @@ COPY packages/redis/package.json packages/redis/
|
||||
COPY packages/logger/package.json packages/logger/
|
||||
COPY packages/common/package.json packages/common/
|
||||
COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
COPY packages/integrations/package.json packages/integrations/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/js-runtime/package.json packages/js-runtime/
|
||||
COPY patches ./patches
|
||||
|
||||
# BUILD
|
||||
@@ -107,10 +106,10 @@ COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/common ./packages/common
|
||||
COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||
RUN pnpm db:codegen
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
340
apps/api/scripts/test-manage-api.ts
Normal 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);
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
649
apps/api/src/controllers/manage.controller.ts
Normal 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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 { getRedisCache, getRedisQueue } from '@openpanel/redis';
|
||||
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type ITrackHandlerPayload,
|
||||
type ITrackPayload,
|
||||
zTrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
return Object.entries(
|
||||
@@ -36,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(
|
||||
@@ -424,7 +419,7 @@ export async function fetchDeviceId(
|
||||
});
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
const multi = getRedisQueue().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
const res = await multi.exec();
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
132
apps/api/src/routes/manage.router.ts
Normal 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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import urlMetadata from 'url-metadata';
|
||||
|
||||
function fallbackFavicon(url: string) {
|
||||
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
||||
} catch {
|
||||
// If URL parsing fails, use the original string
|
||||
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
|
||||
}
|
||||
}
|
||||
|
||||
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||
|
||||
BIN
apps/justfuckinguseopenpanel/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
505
apps/justfuckinguseopenpanel/index.html
Normal file
@@ -0,0 +1,505 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
|
||||
<meta name="author" content="OpenPanel">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta property="og:image" content="/ogimage.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="twitter:image" content="/ogimage.png">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Just Fucking Use OpenPanel</h1>
|
||||
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
||||
|
||||
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
|
||||
|
||||
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
|
||||
<ul>
|
||||
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
|
||||
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
|
||||
</ul>
|
||||
|
||||
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
|
||||
|
||||
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
|
||||
|
||||
<h2>The Web-Only Analytics Trap</h2>
|
||||
|
||||
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
|
||||
|
||||
<blockquote>
|
||||
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
|
||||
</blockquote>
|
||||
|
||||
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
|
||||
|
||||
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
|
||||
|
||||
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
|
||||
|
||||
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
|
||||
|
||||
<h2>Counter One Dollar Stats</h2>
|
||||
|
||||
<p>"$1/month for page views. Adorable."</p>
|
||||
|
||||
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
|
||||
|
||||
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
|
||||
|
||||
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>You get:</p>
|
||||
<ul>
|
||||
<li>Funnels to see where users drop off</li>
|
||||
<li>Retention analysis to see who comes back</li>
|
||||
<li>Cohorts to segment your users</li>
|
||||
<li>User profiles to understand individual behavior</li>
|
||||
<li>Custom dashboards to see what matters to YOU</li>
|
||||
<li>Revenue tracking to see what actually makes money</li>
|
||||
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
|
||||
</ul>
|
||||
|
||||
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
|
||||
|
||||
<h2>Why OpenPanel is the Answer</h2>
|
||||
|
||||
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
|
||||
|
||||
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
|
||||
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
|
||||
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
|
||||
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
|
||||
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
|
||||
</ul>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
|
||||
|
||||
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
|
||||
|
||||
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
|
||||
|
||||
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
|
||||
|
||||
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
|
||||
|
||||
<h2>The Comparison Table (The Brutal Truth)</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Price at 20M events</th>
|
||||
<th>What You Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Mixpanel</strong></td>
|
||||
<td>$2,300+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>PostHog</strong></td>
|
||||
<td>$1,982+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plausible</strong></td>
|
||||
<td>Various pricing</td>
|
||||
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>One Dollar Stats</strong></td>
|
||||
<td>$1/month</td>
|
||||
<td>Page views (but cheaper!)</td>
|
||||
</tr>
|
||||
<tr style="background: #131313; border: 2px solid #3b82f6;">
|
||||
<td><strong>OpenPanel</strong></td>
|
||||
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
|
||||
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The Bottom Fucking Line</h2>
|
||||
|
||||
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
|
||||
|
||||
<p>You have three choices:</p>
|
||||
|
||||
<ol>
|
||||
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
|
||||
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
|
||||
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
|
||||
</ol>
|
||||
|
||||
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
|
||||
|
||||
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Ready to understand what your users actually do?</h2>
|
||||
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
|
||||
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
|
||||
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
|
||||
</figure>
|
||||
|
||||
<footer>
|
||||
<p><strong>Just Fucking Use OpenPanel</strong></p>
|
||||
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
|
||||
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/justfuckinguseopenpanel/ogimage.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/overview-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/profile-dark.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/report-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
7
apps/justfuckinguseopenpanel/wrangler.jsonc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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!
|
||||
|
||||
256
apps/public/content/docs/(tracking)/sdks/nuxt.mdx
Normal 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`.
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -216,3 +216,4 @@ tracker = OpenPanel::SDK::Tracker.new(
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
332
apps/public/content/docs/api/manage/clients.mdx
Normal 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)
|
||||
140
apps/public/content/docs/api/manage/index.mdx
Normal 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
|
||||
5
apps/public/content/docs/api/manage/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Manage",
|
||||
"pages": ["projects", "clients", "references"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
327
apps/public/content/docs/api/manage/projects.mdx
Normal 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
|
||||
344
apps/public/content/docs/api/manage/references.mdx
Normal 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
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"pages": ["track", "export", "insights"]
|
||||
"pages": ["track", "export", "insights", "manage"]
|
||||
}
|
||||
|
||||
101
apps/public/content/docs/migration/migrate-v1-to-v2.mdx
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
171
apps/public/content/guides/astro-analytics.mdx
Normal 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>
|
||||
200
apps/public/content/guides/ecommerce-tracking.mdx
Normal 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>
|
||||
219
apps/public/content/guides/express-analytics.mdx
Normal 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>
|
||||
267
apps/public/content/guides/kotlin-analytics.mdx
Normal 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>
|
||||
232
apps/public/content/guides/migrate-from-google-analytics.mdx
Normal 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>
|
||||
251
apps/public/content/guides/migrate-from-mixpanel.mdx
Normal 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>
|
||||
255
apps/public/content/guides/nextjs-analytics.mdx
Normal 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>
|
||||
324
apps/public/content/guides/nodejs-analytics.mdx
Normal 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.
|
||||
354
apps/public/content/guides/nuxt-analytics.mdx
Normal 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>
|
||||
|
||||
209
apps/public/content/guides/python-analytics.mdx
Normal 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>
|
||||
295
apps/public/content/guides/react-analytics.mdx
Normal 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>
|
||||
216
apps/public/content/guides/react-native-analytics.mdx
Normal 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>
|
||||
387
apps/public/content/guides/remix-analytics.mdx
Normal 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>
|
||||
263
apps/public/content/guides/swift-analytics.mdx
Normal 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.
|
||||
226
apps/public/content/guides/track-custom-events.mdx
Normal 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>
|
||||
348
apps/public/content/guides/vue-analytics.mdx
Normal 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>
|
||||
178
apps/public/content/guides/website-analytics-setup.mdx
Normal 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>
|
||||
|
Before Width: | Height: | Size: 5.7 KiB |
BIN
apps/public/public/logos/lucide-animated.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
|
||||
212
apps/public/src/app/(content)/guides/[guideSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/public/src/app/(content)/guides/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
408
apps/public/src/app/(content)/open-source/page.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
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}
|
||||
>
|
||||
<a
|
||||
href="https://openpanel.dev"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
width: '250px',
|
||||
height: '48px',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
|
||||
height="48"
|
||||
width="100%"
|
||||
style={{
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
title="OpenPanel Analytics Badge"
|
||||
/>
|
||||
</a>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,6 +71,7 @@ export function FeatureCard({
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</FeatureCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]}
|
||||
@@ -74,10 +79,28 @@ export async function Footer() {
|
||||
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||
<div className="container col md:row justify-between gap-8">
|
||||
<div>
|
||||
<Link href="/" className="row items-center font-medium -ml-3">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions().nav?.title}
|
||||
</Link>
|
||||
<a
|
||||
href="https://openpanel.dev"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '8px',
|
||||
width: '100%',
|
||||
height: '48px',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
|
||||
height="48"
|
||||
width="100%"
|
||||
style={{
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
title="OpenPanel Analytics Badge"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<Social />
|
||||
</div>
|
||||
|
||||
67
apps/public/src/components/guide-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
@@ -84,9 +84,16 @@
|
||||
"@types/d3": "^7.4.3",
|
||||
"ai": "^4.2.10",
|
||||
"bind-event-listener": "^3.0.0",
|
||||
"@codemirror/commands": "^6.7.0",
|
||||
"@codemirror/lang-javascript": "^6.2.0",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"d3": "^7.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"debounce": "^2.2.0",
|
||||
@@ -149,7 +156,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 +177,6 @@
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.0.5",
|
||||
"web-vitals": "^4.2.4",
|
||||
"wrangler": "^4.42.2"
|
||||
"wrangler": "4.59.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||