feat: revenue tracking
* wip * wip * wip * wip * show revenue better on overview * align realtime and overview counters * update revenue docs * always return device id * add project settings, improve projects charts, * fix: comments * fixes * fix migration * ignore sql files * fix comments
This commit is contained in:
committed by
GitHub
parent
d61cbf6f2c
commit
790801b728
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function postEvent(
|
||||
request.body,
|
||||
);
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -30,18 +30,22 @@ export async function postEvent(
|
||||
}
|
||||
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function updateProfile(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const ua = request.headers['user-agent'];
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -102,7 +103,7 @@ export async function handler(
|
||||
request.body.payload.properties?.__ip
|
||||
? (request.body.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
if (!projectId) {
|
||||
@@ -115,6 +116,16 @@ export async function handler(
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const profileId = identity?.profileId;
|
||||
const overrideDeviceId = (() => {
|
||||
const deviceId =
|
||||
'properties' in request.body.payload
|
||||
? request.body.payload.properties?.__deviceId
|
||||
: undefined;
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
@@ -125,14 +136,16 @@ export async function handler(
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
@@ -370,3 +383,65 @@ async function decrement({
|
||||
isExternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
const ip = request.clientIp;
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Missing ip address');
|
||||
}
|
||||
|
||||
const ua = request.headers['user-agent'];
|
||||
if (!ua) {
|
||||
return reply.status(400).send('Missing header: user-agent');
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { handler } from '@/controllers/track.controller';
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
@@ -31,6 +31,23 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/device-id',
|
||||
handler: fetchDeviceId,
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default trackRouter;
|
||||
|
||||
@@ -105,6 +105,22 @@ export async function validateSdkRequest(
|
||||
throw createError('Ingestion: Profile id is blocked by project filter');
|
||||
}
|
||||
|
||||
const revenue =
|
||||
path(['payload', 'properties', '__revenue'], req.body) ??
|
||||
path(['properties', '__revenue'], req.body);
|
||||
|
||||
// Only allow revenue tracking if it was sent with a client secret
|
||||
// or if the project has allowUnsafeRevenueTracking enabled
|
||||
if (
|
||||
!client.project.allowUnsafeRevenueTracking &&
|
||||
!clientSecret &&
|
||||
typeof revenue !== 'undefined'
|
||||
) {
|
||||
throw createError(
|
||||
'Ingestion: Revenue tracking is not allowed without a client secret',
|
||||
);
|
||||
}
|
||||
|
||||
if (client.ignoreCorsAndSecret) {
|
||||
return client;
|
||||
}
|
||||
|
||||
79
apps/public/components/flow-step.tsx
Normal file
79
apps/public/components/flow-step.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FlowStepProps {
|
||||
step: number;
|
||||
actor: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
visitor: User,
|
||||
website: Globe,
|
||||
backend: Server,
|
||||
payment: CreditCard,
|
||||
success: CheckCircle,
|
||||
};
|
||||
|
||||
const iconColorMap = {
|
||||
visitor: 'text-blue-500',
|
||||
website: 'text-green-500',
|
||||
backend: 'text-purple-500',
|
||||
payment: 'text-yellow-500',
|
||||
success: 'text-green-600',
|
||||
};
|
||||
|
||||
const iconBorderColorMap = {
|
||||
visitor: 'border-blue-500',
|
||||
website: 'border-green-500',
|
||||
backend: 'border-purple-500',
|
||||
payment: 'border-yellow-500',
|
||||
success: 'border-green-600',
|
||||
};
|
||||
|
||||
export function FlowStep({
|
||||
step,
|
||||
actor,
|
||||
description,
|
||||
children,
|
||||
icon = 'visitor',
|
||||
isLast = false,
|
||||
}: FlowStepProps) {
|
||||
const Icon = iconMap[icon];
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 mb-4 min-w-0">
|
||||
{/* Step number and icon */}
|
||||
<div className="flex flex-col items-center flex-shrink-0">
|
||||
<div className="relative z-10 bg-background">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
|
||||
{step}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
|
||||
>
|
||||
<Icon
|
||||
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector line - extends from badge through content to next step */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pt-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
{children && <div className="mt-3 min-w-0">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
apps/public/content/docs/get-started/revenue-tracking.mdx
Normal file
364
apps/public/content/docs/get-started/revenue-tracking.mdx
Normal file
@@ -0,0 +1,364 @@
|
||||
---
|
||||
title: Revenue tracking
|
||||
description: Learn how to easily track your revenue with OpenPanel and how to get it shown directly in your dashboard.
|
||||
---
|
||||
|
||||
import { FlowStep } from '@/components/flow-step';
|
||||
|
||||
Revenue tracking is a great way to get a better understanding of what your best revenue source is. On this page we'll break down how to get started.
|
||||
|
||||
Before we start, we need to know some fundamentals about how OpenPanel and your payment provider work and how we can link a payment to a visitor.
|
||||
|
||||
### Payment providers
|
||||
|
||||
Usually, you create your checkout from your backend, which then returns a payment link that your visitor will be redirected to. When creating the checkout link, you usually add additional fields such as metadata, customer information, or order details. We'll add the device ID information in this metadata field to be able to link your payment to a visitor.
|
||||
|
||||
### OpenPanel
|
||||
|
||||
OpenPanel is a cookieless analytics tool that identifies visitors using a `device_id`. To link a payment to a visitor, you need to capture their `device_id` before they complete checkout. This `device_id` will be stored in your payment provider's metadata, and when the payment webhook arrives, you'll use it to associate the revenue with the correct visitor.
|
||||
|
||||
## Some typical flows
|
||||
|
||||
- [Revenue tracking from your backend (not identified)](#revenue-tracking-from-your-backend-webhook)
|
||||
- [Revenue tracking from your backend (identified)](#revenue-tracking-from-your-backend-webhook-identified)
|
||||
- [Revenue tracking from your frontend](#revenue-tracking-from-your-frontend)
|
||||
- [Revenue tracking without linking it to a identity or device](#revenue-tracking-without-linking-it-to-an-identity-or-device)
|
||||
|
||||
### Revenue tracking from your backend (webhook)
|
||||
|
||||
This is the most common flow and most secure one. Your backend receives webhooks from your payment provider, and here is the best opportunity to do revenue tracking.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
|
||||
When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint.
|
||||
|
||||
```javascript
|
||||
fetch('https://domain.com/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now
|
||||
// ... other checkout data
|
||||
}),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Handle checkout response, e.g., redirect to payment link
|
||||
window.location.href = data.paymentUrl;
|
||||
})
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={4} actor="Your backend" description="Will generate and return the checkout URL" icon="backend">
|
||||
```javascript
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { deviceId, amount, currency } = await req.json();
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: currency,
|
||||
product_data: { name: 'Product Name' },
|
||||
unit_amount: amount * 100, // Convert to cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
metadata: {
|
||||
deviceId: deviceId, // ✅ since deviceId is here we can link the payment now
|
||||
},
|
||||
success_url: 'https://domain.com/success',
|
||||
cancel_url: 'https://domain.com/cancel',
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
paymentUrl: session.url,
|
||||
});
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={5} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
|
||||
|
||||
<FlowStep step={6} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={7} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend">
|
||||
```javascript
|
||||
export async function POST(req: Request) {
|
||||
const event = await req.json();
|
||||
|
||||
// Stripe sends events with type and data.object structure
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const deviceId = session.metadata.deviceId;
|
||||
const amount = session.amount_total;
|
||||
|
||||
op.revenue(amount, { deviceId }); // ✅ since deviceId is here we can link the payment now
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={8} actor="Visitor" description="Redirected to your website with payment confirmation" icon="success" isLast />
|
||||
|
||||
---
|
||||
|
||||
### Revenue tracking from your backend (webhook) - Identified users
|
||||
|
||||
If your visitors are identified (meaning you have called `identify` with a `profileId`), this process gets a bit easier. You don't need to pass the `deviceId` when creating your checkout, and you only need to provide the `profileId` (in backend) to the revenue call.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Your website" description="Identifies the visitor" icon="website">
|
||||
When a visitor logs in or is identified, call `op.identify()` with their unique `profileId`.
|
||||
|
||||
```javascript
|
||||
op.identify({
|
||||
profileId: 'user-123', // Unique identifier for this user
|
||||
email: 'user@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={3} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={4} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
|
||||
Since the visitor is already identified, you don't need to fetch or pass the `deviceId`. Just send the checkout data.
|
||||
|
||||
```javascript
|
||||
fetch('https://domain.com/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// ✅ No deviceId needed - user is already identified
|
||||
// ... other checkout data
|
||||
}),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Handle checkout response, e.g., redirect to payment link
|
||||
window.location.href = data.paymentUrl;
|
||||
})
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={5} actor="Your backend" description="Will generate and return the checkout URL" icon="backend">
|
||||
Since the user is authenticated, you can get their `profileId` from the session and store it in metadata for easy retrieval in the webhook.
|
||||
|
||||
```javascript
|
||||
import Stripe from 'stripe';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { amount, currency } = await req.json();
|
||||
|
||||
// Get profileId from authenticated session
|
||||
const profileId = req.session.userId; // or however you get the user ID
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: currency,
|
||||
product_data: { name: 'Product Name' },
|
||||
unit_amount: amount * 100, // Convert to cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
metadata: {
|
||||
profileId: profileId, // ✅ Store profileId instead of deviceId
|
||||
},
|
||||
success_url: 'https://domain.com/success',
|
||||
cancel_url: 'https://domain.com/cancel',
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
paymentUrl: session.url,
|
||||
});
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={6} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
|
||||
|
||||
<FlowStep step={7} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={8} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend">
|
||||
In the webhook handler, retrieve the `profileId` from the session metadata.
|
||||
|
||||
```javascript
|
||||
export async function POST(req: Request) {
|
||||
const event = await req.json();
|
||||
|
||||
// Stripe sends events with type and data.object structure
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const profileId = session.metadata.profileId;
|
||||
const amount = session.amount_total;
|
||||
|
||||
op.revenue(amount, { profileId }); // ✅ Use profileId instead of deviceId
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={9} actor="Visitor" description="Redirected to your website with payment confirmation" icon="success" isLast />
|
||||
|
||||
---
|
||||
|
||||
### Revenue tracking from your frontend
|
||||
|
||||
This flow tracks revenue directly from your frontend. Since the success page doesn't have access to the payment amount (payment happens on Stripe's side), we track revenue when checkout is initiated and then confirm it on the success page.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Visits your website" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Visitor" description="Clicks to purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={3} actor="Your website" description="Track revenue when checkout is initiated" icon="website">
|
||||
When the visitor clicks the checkout button, track the revenue with the amount.
|
||||
|
||||
```javascript
|
||||
async function handleCheckout() {
|
||||
const amount = 2000; // Amount in cents
|
||||
|
||||
// Create a pending revenue (stored in sessionStorage)
|
||||
op.pendingRevenue(amount, {
|
||||
productId: '123',
|
||||
// ... other properties
|
||||
});
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = 'https://checkout.stripe.com/...';
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
<FlowStep step={4} actor="Visitor" description="Gets redirected to payment link" icon="visitor" />
|
||||
|
||||
<FlowStep step={5} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={6} actor="Visitor" description="Redirected back to your success page" icon="visitor" />
|
||||
|
||||
<FlowStep step={7} actor="Your website" description="Confirm/flush the revenue on success page" icon="website" isLast>
|
||||
On your success page, flush all pending revenue events. This will send all pending revenues tracked during checkout and clear them from sessionStorage.
|
||||
|
||||
```javascript
|
||||
// Flush all pending revenues
|
||||
await op.flushRevenue();
|
||||
|
||||
// Or if you want to clear without sending (e.g., payment was cancelled)
|
||||
op.clearRevenue();
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
#### Pros:
|
||||
- Quick way to get going
|
||||
- No backend required
|
||||
- Can track revenue immediately when checkout starts
|
||||
|
||||
#### Cons:
|
||||
- Less accurate (visitor might not complete payment)
|
||||
- Less "secure" meaning anyone could post revenue data
|
||||
|
||||
---
|
||||
|
||||
### Revenue tracking without linking it to an identity or device
|
||||
|
||||
If you simply want to track revenue totals without linking payments to specific visitors or devices, you can call `op.revenue()` directly from your backend without providing a `deviceId` or `profileId`. This is the simplest approach and works well when you only need aggregate revenue data.
|
||||
|
||||
<FlowStep step={1} actor="Visitor" description="Makes a purchase" icon="visitor" />
|
||||
|
||||
<FlowStep step={2} actor="Visitor" description="Pays on your payment provider" icon="payment" />
|
||||
|
||||
<FlowStep step={3} actor="Your backend" description="Receives a webhook for a successful payment" icon="backend" isLast>
|
||||
Simply call `op.revenue()` with the amount. No `deviceId` or `profileId` is needed.
|
||||
|
||||
```javascript
|
||||
export async function POST(req: Request) {
|
||||
const event = await req.json();
|
||||
|
||||
// Stripe sends events with type and data.object structure
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const amount = session.amount_total;
|
||||
|
||||
op.revenue(amount); // ✅ Simple revenue tracking without linking to a visitor
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
```
|
||||
</FlowStep>
|
||||
|
||||
#### Pros:
|
||||
- Simplest implementation
|
||||
- No need to capture or pass device IDs
|
||||
- Works well for aggregate revenue tracking
|
||||
|
||||
#### Cons:
|
||||
- **You can't dive deeper into where this revenue came from.** For instance, you won't be able to see which source generates the best revenue, which campaigns are most profitable, or which visitors are your highest-value customers.
|
||||
- Revenue events won't be linked to specific user journeys or sessions
|
||||
|
||||
## Available methods
|
||||
|
||||
### Revenue
|
||||
|
||||
The revenue method will create a revenue event. It's important to know that this method will not work if your OpenPanel instance didn't receive a client secret (for security reasons). You can enable frontend revenue tracking within your project settings.
|
||||
|
||||
```javascript
|
||||
op.revenue(amount: number, properties: Record<string, unknown>): Promise<void>
|
||||
```
|
||||
|
||||
### Add a pending revenue
|
||||
|
||||
This method will create a pending revenue item and store it in sessionStorage. It will not be sent to OpenPanel until you call `flushRevenue()`. Pending revenues are automatically restored from sessionStorage when the SDK initializes.
|
||||
|
||||
```javascript
|
||||
op.pendingRevenue(amount: number, properties?: Record<string, unknown>): void
|
||||
```
|
||||
|
||||
### Send all pending revenues
|
||||
|
||||
This method will send all pending revenues to OpenPanel and then clear them from sessionStorage. Returns a Promise that resolves when all revenues have been sent.
|
||||
|
||||
```javascript
|
||||
await op.flushRevenue(): Promise<void>
|
||||
```
|
||||
|
||||
### Clear any pending revenue
|
||||
|
||||
This method will clear all pending revenues from memory and sessionStorage without sending them to OpenPanel. Useful if a payment was cancelled or you want to discard pending revenues.
|
||||
|
||||
```javascript
|
||||
op.clearRevenue(): void
|
||||
```
|
||||
|
||||
### Fetch your current users device id
|
||||
|
||||
```javascript
|
||||
op.fetchDeviceId(): Promise<string>
|
||||
```
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm with-env next dev",
|
||||
"dev": "pnpm with-env next dev --port 3001",
|
||||
"build": "pnpm with-env next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as d3 from 'd3';
|
||||
|
||||
export function ChartSSR({
|
||||
data,
|
||||
dots = false,
|
||||
color = 'blue',
|
||||
}: {
|
||||
dots?: boolean;
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
data: { value: number; date: Date }[];
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xScale = d3
|
||||
.scaleTime()
|
||||
.domain([data[0]!.date, data[data.length - 1]!.date])
|
||||
.range([0, 100]);
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
|
||||
.range([100, 0]);
|
||||
|
||||
const line = d3
|
||||
.line<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y((d) => yScale(d.value));
|
||||
|
||||
const area = d3
|
||||
.area<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y0(yScale(0))
|
||||
.y1((d) => yScale(d.value));
|
||||
|
||||
const pathLine = line(data);
|
||||
const pathArea = area(data);
|
||||
|
||||
if (!pathLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gradientId = `gradient-${color}`;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Chart area */}
|
||||
<svg className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="100%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
||||
<stop offset="50%" stopColor={color} stopOpacity={0.05} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Gradient area */}
|
||||
{pathArea && (
|
||||
<path
|
||||
d={pathArea}
|
||||
fill={`url(#${gradientId})`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathLine}
|
||||
fill="none"
|
||||
className={
|
||||
color === 'green'
|
||||
? 'text-green-600'
|
||||
: color === 'red'
|
||||
? 'text-red-600'
|
||||
: 'text-highlight'
|
||||
}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Circles */}
|
||||
{dots &&
|
||||
data.map((d) => (
|
||||
<path
|
||||
key={d.date.toString()}
|
||||
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-gray-400"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { createContext, useContext as useBaseContext } from 'react';
|
||||
|
||||
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
||||
@@ -21,11 +22,18 @@ export const ChartTooltipHeader = ({
|
||||
export const ChartTooltipItem = ({
|
||||
children,
|
||||
color,
|
||||
}: { children: React.ReactNode; color: string }) => {
|
||||
className,
|
||||
innerClassName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
color: string;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<div className="w-[3px] rounded-full" style={{ background: color }} />
|
||||
<div className="col flex-1 gap-1">{children}</div>
|
||||
<div className={cn('col flex-1 gap-1', innerClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,6 +77,15 @@ export const BarShapeBlue = BarWithBorder({
|
||||
fill: 'rgba(59, 121, 255, 0.4)',
|
||||
},
|
||||
});
|
||||
export const BarShapeGreen = BarWithBorder({
|
||||
borderHeight: 2,
|
||||
border: 'rgba(59, 169, 116, 1)',
|
||||
fill: 'rgba(59, 169, 116, 0.3)',
|
||||
active: {
|
||||
border: 'rgba(59, 169, 116, 1)',
|
||||
fill: 'rgba(59, 169, 116, 0.4)',
|
||||
},
|
||||
});
|
||||
export const BarShapeProps = BarWithBorder({
|
||||
borderHeight: 2,
|
||||
border: 'props',
|
||||
|
||||
@@ -48,6 +48,10 @@ export const EventIconRecords: Record<
|
||||
icon: 'ExternalLinkIcon',
|
||||
color: 'indigo',
|
||||
},
|
||||
revenue: {
|
||||
icon: 'DollarSignIcon',
|
||||
color: 'green',
|
||||
},
|
||||
};
|
||||
|
||||
export const EventIconMapper: Record<string, LucideIcon> = {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
}}
|
||||
data-slot="inner"
|
||||
className={cn(
|
||||
'col gap-2 flex-1 p-2',
|
||||
'col gap-1 flex-1 p-2',
|
||||
// Desktop
|
||||
'@lg:row @lg:items-center',
|
||||
'cursor-pointer',
|
||||
@@ -63,7 +63,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
: 'hover:bg-def-200',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 row items-center gap-4">
|
||||
<div className="min-w-0 flex-1 row items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
@@ -77,7 +77,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
>
|
||||
<EventIcon name={event.name} size="sm" meta={event.meta} />
|
||||
</button>
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all">
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all text-sm leading-normal">
|
||||
{event.name === 'screen_view' ? (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Visit:</span>
|
||||
@@ -87,13 +87,12 @@ export const EventItem = memo<EventItemProps>(
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Event:</span>
|
||||
<span className="font-medium">{event.name}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-2 items-center @max-lg:pl-10">
|
||||
<div className="row gap-2 items-center @max-lg:pl-8">
|
||||
{event.referrerName && viewOptions.referrerName !== false && (
|
||||
<Pill
|
||||
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
|
||||
|
||||
@@ -108,8 +108,8 @@ function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="row gap-2 justify-between">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} sessions last 30 minutes
|
||||
<div className="relative mb-1 text-xs font-medium text-muted-foreground">
|
||||
{count} sessions last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Area, AreaChart, Tooltip } from 'recharts';
|
||||
import { formatDate, timeAgo } from '@/utils/date';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
@@ -24,12 +24,13 @@ interface MetricCardProps {
|
||||
data: {
|
||||
current: number;
|
||||
previous?: number;
|
||||
date: string;
|
||||
}[];
|
||||
metric: {
|
||||
current: number;
|
||||
previous?: number | null;
|
||||
};
|
||||
unit?: '' | 'date' | 'timeAgo' | 'min' | '%';
|
||||
unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency';
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
@@ -48,9 +49,28 @@ export function OverviewMetricCard({
|
||||
inverted = false,
|
||||
isLoading = false,
|
||||
}: MetricCardProps) {
|
||||
const [value, setValue] = useState(metric.current);
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
const number = useNumber();
|
||||
const { current, previous } = metric;
|
||||
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
|
||||
if (currentIndex) {
|
||||
timer.current = setTimeout(() => {
|
||||
setCurrentIndex(null);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
};
|
||||
}, [currentIndex]);
|
||||
|
||||
const renderValue = (value: number, unitClassName?: string, short = true) => {
|
||||
if (unit === 'date') {
|
||||
@@ -65,6 +85,11 @@ export function OverviewMetricCard({
|
||||
return <>{fancyMinutes(value)}</>;
|
||||
}
|
||||
|
||||
if (unit === 'currency') {
|
||||
// Revenue is stored in cents, convert to dollars
|
||||
return <>{number.currency(value / 100)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{short ? number.short(value) : number.format(value)}
|
||||
@@ -81,19 +106,33 @@ export function OverviewMetricCard({
|
||||
'#93c5fd', // blue
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltiper
|
||||
content={
|
||||
const renderTooltip = () => {
|
||||
if (currentIndex) {
|
||||
return (
|
||||
<span>
|
||||
{label}:{' '}
|
||||
{formatDate(new Date(data[currentIndex]?.date))}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(value, 'ml-1 font-light text-xl', false)}
|
||||
{renderValue(
|
||||
data[currentIndex].current,
|
||||
'ml-1 font-light text-xl',
|
||||
false,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
sideOffset={-20}
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(metric.current, 'ml-1 font-light text-xl', false)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -116,9 +155,7 @@ export function OverviewMetricCard({
|
||||
data={data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
onMouseMove={(event) => {
|
||||
setValue(
|
||||
event.activePayload?.[0]?.payload?.current ?? current,
|
||||
);
|
||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
@@ -13,24 +12,22 @@ import { getPreviousMetric } from '@openpanel/common';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { last, omit } from 'ramda';
|
||||
import { last } from 'ramda';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Customized,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { createChartTooltip } from '../charts/chart-tooltip';
|
||||
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
|
||||
import { Skeleton } from '../skeleton';
|
||||
@@ -78,6 +75,12 @@ const TITLES = [
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
key: 'total_revenue',
|
||||
unit: 'currency',
|
||||
inverted: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
@@ -86,11 +89,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
|
||||
'chartType',
|
||||
'bars',
|
||||
);
|
||||
|
||||
const activeMetric = TITLES[metric]!;
|
||||
const overviewQuery = useQuery(
|
||||
trpc.overview.stats.queryOptions({
|
||||
@@ -125,6 +123,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
}}
|
||||
unit={title.unit}
|
||||
data={data.map((item) => ({
|
||||
date: item.date,
|
||||
current: item[title.key],
|
||||
previous: item[`prev_${title.key}`],
|
||||
}))}
|
||||
@@ -136,7 +135,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2',
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1',
|
||||
)}
|
||||
>
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
@@ -148,32 +147,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('bars')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'bars'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('lines')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'lines'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Lines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
@@ -181,7 +154,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
activeMetric={activeMetric}
|
||||
interval={interval}
|
||||
data={data}
|
||||
chartType={chartType}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
@@ -194,18 +166,25 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RouterOutputs['overview']['stats']['series'][number],
|
||||
{
|
||||
anyMetric?: boolean;
|
||||
metric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
}
|
||||
>(({ context: { metric, interval }, data: dataArray }) => {
|
||||
>(({ context: { metric, interval, anyMetric }, data: dataArray }) => {
|
||||
const data = dataArray[0];
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revenue = data.total_revenue ?? 0;
|
||||
const prevRevenue = data.prev_total_revenue ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
@@ -215,16 +194,25 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
style={{ background: anyMetric ? getChartColor(0) : '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{metric.title}</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data[metric.key])}
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[metric.key] ?? 0) / 100)
|
||||
: number.formatWithUnit(data[metric.key], metric.unit)}
|
||||
{!!data[`prev_${metric.key}`] && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data[`prev_${metric.key}`])})
|
||||
(
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[`prev_${metric.key}`] ?? 0) / 100)
|
||||
: number.formatWithUnit(
|
||||
data[`prev_${metric.key}`],
|
||||
metric.unit,
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -238,6 +226,32 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{anyMetric && revenue > 0 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.currency(revenue / 100)}
|
||||
{prevRevenue > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.currency(prevRevenue / 100)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prevRevenue > 0 && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(revenue, prevRevenue)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
@@ -247,17 +261,19 @@ function Chart({
|
||||
activeMetric,
|
||||
interval,
|
||||
data,
|
||||
chartType,
|
||||
projectId,
|
||||
}: {
|
||||
activeMetric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
data: RouterOutputs['overview']['stats']['series'];
|
||||
chartType: 'bars' | 'lines';
|
||||
projectId: string;
|
||||
}) {
|
||||
const xAxisProps = useXAxisProps({ interval });
|
||||
const yAxisProps = useYAxisProps();
|
||||
const number = useNumber();
|
||||
const revenueYAxisProps = useYAxisProps({
|
||||
tickFormatter: (value) => number.short(value / 100),
|
||||
});
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
|
||||
@@ -278,13 +294,11 @@ function Chart({
|
||||
|
||||
// Line chart specific logic
|
||||
let dotIndex = undefined;
|
||||
if (chartType === 'lines') {
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
|
||||
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
|
||||
@@ -294,6 +308,10 @@ function Chart({
|
||||
|
||||
const lastSerieDataItem = last(data)?.date || new Date();
|
||||
const useDashedLastLine = (() => {
|
||||
if (range === 'today') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interval === 'hour') {
|
||||
return isSameHour(lastSerieDataItem, new Date());
|
||||
}
|
||||
@@ -313,11 +331,11 @@ function Chart({
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (chartType === 'lines') {
|
||||
if (activeMetric.key === 'total_revenue') {
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<ComposedChart data={data}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
@@ -326,13 +344,8 @@ function Chart({
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
@@ -340,10 +353,30 @@ function Chart({
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="linear"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
key={'prev_total_revenue'}
|
||||
type="monotone"
|
||||
dataKey={'prev_total_revenue'}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
@@ -352,24 +385,26 @@ function Chart({
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'var(--def-100)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'var(--def-100)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={activeMetric.key}
|
||||
type="linear"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
<Area
|
||||
key={'total_revenue'}
|
||||
type="monotone"
|
||||
dataKey={'total_revenue'}
|
||||
stroke={'#3ba974'}
|
||||
fill={'#3ba974'}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
@@ -381,18 +416,19 @@ function Chart({
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
stroke: '#3ba974',
|
||||
fill: '#3ba974',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
stroke: '#3ba974',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
@@ -410,36 +446,48 @@ function Chart({
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Bar chart (default)
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<TooltipProvider metric={activeMetric} interval={interval} anyMetric={true}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
setActiveBar(e.activeTooltipIndex ?? -1);
|
||||
}}
|
||||
barCategoryGap={2}
|
||||
>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: 'var(--def-200)',
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<XAxis {...omit(['scale', 'type'], xAxisProps)} />
|
||||
<YAxis
|
||||
{...revenueYAxisProps}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[
|
||||
0,
|
||||
data.reduce(
|
||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||
0,
|
||||
) * 2,
|
||||
]}
|
||||
width={30}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -448,21 +496,103 @@ function Chart({
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="monotone"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
shape={(props: any) => (
|
||||
<BarShapeGrey isActive={activeBar === props.index} {...props} />
|
||||
)}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
key={activeMetric.key}
|
||||
dataKey={activeMetric.key}
|
||||
key="total_revenue"
|
||||
dataKey="total_revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
shape={(props: any) => (
|
||||
<BarShapeBlue isActive={activeBar === props.index} {...props} />
|
||||
)}
|
||||
radius={5}
|
||||
maxBarSize={20}
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.date}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Area
|
||||
key={activeMetric.key}
|
||||
type="monotone"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
fill={getChartColor(0)}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
@@ -480,7 +610,7 @@ function Chart({
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,43 @@ import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
|
||||
|
||||
function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const size = 16;
|
||||
const strokeWidth = 2;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - percentage * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="flex-shrink-0">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-def-200"
|
||||
/>
|
||||
{/* Revenue arc */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#3ba974"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props<T> = WidgetTableProps<T> & {
|
||||
getColumnPercentage: (item: T) => number;
|
||||
};
|
||||
@@ -45,9 +82,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
: 'text-right font-mono',
|
||||
index !== 0 &&
|
||||
index !== columns.length - 1 &&
|
||||
'hidden @[310px]:table-cell',
|
||||
// Remove old responsive logic - now handled by responsive prop
|
||||
column.className,
|
||||
),
|
||||
};
|
||||
@@ -119,12 +154,15 @@ export function OverviewWidgetTablePages({
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}[];
|
||||
showDomain?: boolean;
|
||||
}) {
|
||||
const [_filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
|
||||
const hasRevenue = data.some((item) => item.revenue > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
@@ -135,6 +173,7 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'Path',
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
@@ -178,6 +217,7 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -185,13 +225,41 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'Duration',
|
||||
width: '75px',
|
||||
responsive: { priority: 7 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.avg_duration, 'min');
|
||||
},
|
||||
},
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{item.revenue > 0
|
||||
? number.currency(item.revenue / 100)
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: lastColumnName,
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -303,20 +371,24 @@ export function OverviewWidgetTableGeneric({
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.name}
|
||||
keyExtractor={(item) => item.prefix + item.name}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
...column,
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -327,9 +399,38 @@ export function OverviewWidgetTableGeneric({
|
||||
// return number.shortWithUnit(item.avg_session_duration, 'min');
|
||||
// },
|
||||
// },
|
||||
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{revenue > 0
|
||||
? number.currency(revenue / 100, { short: true })
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
|
||||
@@ -12,72 +12,91 @@ const PROFILE_METRICS = [
|
||||
key: 'totalEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Sessions',
|
||||
key: 'sessions',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Page Views',
|
||||
key: 'screenViews',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Events/Session',
|
||||
key: 'avgEventsPerSession',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Bounce Rate',
|
||||
key: 'bounceRate',
|
||||
unit: '%',
|
||||
inverted: true,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (Avg)',
|
||||
key: 'durationAvg',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (P90)',
|
||||
key: 'durationP90',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'First seen',
|
||||
key: 'firstSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
key: 'lastSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Days Active',
|
||||
key: 'uniqueDaysActive',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversion Events',
|
||||
key: 'conversionEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Time Between Sessions (h)',
|
||||
key: 'avgTimeBetweenSessions',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
key: 'revenue',
|
||||
unit: 'currency',
|
||||
inverted: false,
|
||||
hideOnZero: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -85,7 +104,12 @@ export const ProfileMetrics = ({ data }: Props) => {
|
||||
return (
|
||||
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
||||
{PROFILE_METRICS.map((metric) => (
|
||||
{PROFILE_METRICS.filter((metric) => {
|
||||
if (metric.hideOnZero && data[metric.key] === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((metric) => (
|
||||
<OverviewMetricCard
|
||||
key={metric.key}
|
||||
id={metric.key}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shortNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
@@ -7,11 +7,11 @@ import type { IServiceProject } from '@openpanel/db';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
import { ChartSSR } from '../chart-ssr';
|
||||
import { FadeIn } from '../fade-in';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { LinkButton } from '../ui/button';
|
||||
import { ProjectChart } from './project-chart';
|
||||
|
||||
export function ProjectCardRoot({
|
||||
children,
|
||||
@@ -60,7 +60,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-4 aspect-[8/1] mb-4">
|
||||
<ProjectChart id={id} />
|
||||
<ProjectChartOuter id={id} />
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 h-9 md:h-4">
|
||||
<ProjectMetrics id={id} />
|
||||
@@ -77,7 +77,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectChart({ id }: { id: string }) {
|
||||
function ProjectChartOuter({ id }: { id: string }) {
|
||||
const trpc = useTRPC();
|
||||
const { data } = useQuery(
|
||||
trpc.chart.projectCard.queryOptions({
|
||||
@@ -87,7 +87,7 @@ function ProjectChart({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<FadeIn className="h-full w-full">
|
||||
<ChartSSR data={data?.chart || []} color={'blue'} />
|
||||
<ProjectChart data={data?.chart || []} color={'blue'} />
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,7 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
|
||||
}
|
||||
|
||||
function ProjectMetrics({ id }: { id: string }) {
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const { data } = useQuery(
|
||||
trpc.chart.projectCard.queryOptions({
|
||||
@@ -138,16 +139,18 @@ function ProjectMetrics({ id }: { id: string }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!data?.metrics?.revenue && (
|
||||
<Metric
|
||||
label="Revenue"
|
||||
value={number.currency(data?.metrics?.revenue / 100, {
|
||||
short: true,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Metric
|
||||
label="3M"
|
||||
value={shortNumber('en')(data?.metrics?.months_3 ?? 0)}
|
||||
/>
|
||||
<Metric
|
||||
label="30D"
|
||||
value={shortNumber('en')(data?.metrics?.month ?? 0)}
|
||||
/>
|
||||
<Metric label="24H" value={shortNumber('en')(data?.metrics?.day ?? 0)} />
|
||||
<Metric label="3M" value={number.short(data?.metrics?.months_3 ?? 0)} />
|
||||
<Metric label="30D" value={number.short(data?.metrics?.month ?? 0)} />
|
||||
<Metric label="24H" value={number.short(data?.metrics?.day ?? 0)} />
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
215
apps/start/src/components/projects/project-chart.tsx
Normal file
215
apps/start/src/components/projects/project-chart.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type ChartDataItem = {
|
||||
value: number;
|
||||
date: Date;
|
||||
revenue: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
ChartDataItem,
|
||||
{
|
||||
color: 'blue' | 'green' | 'red';
|
||||
}
|
||||
>(
|
||||
({
|
||||
context,
|
||||
data: dataArray,
|
||||
}: {
|
||||
context: { color: 'blue' | 'green' | 'red' };
|
||||
data: ChartDataItem[];
|
||||
}) => {
|
||||
const { color } = context;
|
||||
const data = dataArray[0];
|
||||
const number = useNumber();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getColorValue = () => {
|
||||
if (color === 'green') return '#16a34a';
|
||||
if (color === 'red') return '#dc2626';
|
||||
return getChartColor(0);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div className="text-muted-foreground">{formatDate(data.date)}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem
|
||||
color={getColorValue()}
|
||||
innerClassName="row justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">Sessions</div>
|
||||
<div className="font-mono font-bold">{number.format(data.value)}</div>
|
||||
</ChartTooltipItem>
|
||||
{data.revenue > 0 && (
|
||||
<ChartTooltipItem color="#3ba974">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="font-mono font-medium">
|
||||
{number.currency(data.revenue / 100)}
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function ProjectChart({
|
||||
data,
|
||||
dots = false,
|
||||
color = 'blue',
|
||||
}: {
|
||||
dots?: boolean;
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
data: { value: number; date: Date; revenue: number }[];
|
||||
}) {
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform data for Recharts (needs timestamp for time-based x-axis)
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
timestamp: item.date.getTime(),
|
||||
}));
|
||||
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 0);
|
||||
const maxRevenue = Math.max(...data.map((d) => d.revenue), 0);
|
||||
|
||||
const getColorValue = () => {
|
||||
if (color === 'green') return '#16a34a';
|
||||
if (color === 'red') return '#dc2626';
|
||||
return getChartColor(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<TooltipProvider color={color}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
setActiveBar(e.activeTooltipIndex ?? -1);
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
hide
|
||||
/>
|
||||
<YAxis domain={[0, maxValue || 'dataMax']} hide width={0} />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, maxRevenue * 2 || 'dataMax']}
|
||||
hide
|
||||
width={0}
|
||||
/>
|
||||
|
||||
<Tooltip />
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={getColorValue()}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
dots && data.length <= 90
|
||||
? {
|
||||
stroke: getColorValue(),
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
: false
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getColorValue(),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey="revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
radius={5}
|
||||
maxBarSize={20}
|
||||
>
|
||||
{chartData.map((item, index) => (
|
||||
<Cell
|
||||
key={item.timestamp}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +1,99 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { BarShapeBlue } from '../charts/common-bar';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const getReport = (projectId: string): IChartProps => {
|
||||
return {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
name: '*',
|
||||
displayName: 'Active users',
|
||||
},
|
||||
],
|
||||
chartType: 'histogram',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
name: '',
|
||||
metric: 'sum',
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
previous: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCountReport = (projectId: string): IChartProps => {
|
||||
return {
|
||||
name: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
};
|
||||
|
||||
export function RealtimeLiveHistogram({
|
||||
projectId,
|
||||
}: RealtimeLiveHistogramProps) {
|
||||
const report = getReport(projectId);
|
||||
const countReport = getCountReport(projectId);
|
||||
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(trpc.chart.chart.queryOptions(report));
|
||||
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
|
||||
|
||||
const metrics = res.data?.series[0]?.metrics;
|
||||
const minutes = (res.data?.series[0]?.data || []).slice(-30);
|
||||
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
|
||||
// Use the same liveData endpoint as overview
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
);
|
||||
|
||||
if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) {
|
||||
const staticArray = [
|
||||
10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52,
|
||||
5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5,
|
||||
];
|
||||
const chartData = liveData?.minuteCounts ?? [];
|
||||
// Calculate total unique visitors (sum of unique visitors per minute)
|
||||
// Note: This is an approximation - ideally we'd want unique visitors across all minutes
|
||||
const totalVisitors = liveData?.totalSessions ?? 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
{staticArray.map((percent, i) => (
|
||||
<div
|
||||
key={i as number}
|
||||
className="flex-1 animate-pulse rounded-sm bg-def-200"
|
||||
style={{ height: `${percent}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.isSuccess && !countRes.isSuccess) {
|
||||
if (!liveData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDomain =
|
||||
Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1;
|
||||
|
||||
return (
|
||||
<Wrapper count={liveCount}>
|
||||
{minutes.map((minute) => {
|
||||
return (
|
||||
<Tooltip key={minute.date}>
|
||||
<TooltipTrigger asChild>
|
||||
<Wrapper
|
||||
count={totalVisitors}
|
||||
icons={
|
||||
liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
<div className="row gap-2">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110',
|
||||
minute.count === 0 ? 'bg-def-200' : 'bg-highlight',
|
||||
)}
|
||||
style={{
|
||||
height:
|
||||
minute.count === 0
|
||||
? '20%'
|
||||
: `${(minute.count / metrics!.max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div>{minute.count} active users</div>
|
||||
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Tooltip
|
||||
content={CustomTooltip}
|
||||
cursor={{
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="visitorCount"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
activeBar={BarShapeBlue}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -128,22 +101,144 @@ export function RealtimeLiveHistogram({
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
count: number;
|
||||
icons?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Unique vistors last 30 minutes
|
||||
<div className="row gap-2 justify-between mb-2">
|
||||
<div className="relative text-sm font-medium text-muted-foreground leading-normal">
|
||||
Unique visitors {icons ? <br /> : null}
|
||||
last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="col gap-2 mb-4">
|
||||
<div className="font-mono text-6xl font-bold">
|
||||
<AnimatedNumber value={count} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
<div className="relative aspect-[6/1] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom tooltip component that uses portals to escape overflow hidden
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
type: 'mousemove',
|
||||
listener(event) {
|
||||
if (!inactive) {
|
||||
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
|
||||
}
|
||||
},
|
||||
});
|
||||
const unsubDragEnter = bind(window, {
|
||||
type: 'pointerdown',
|
||||
listener() {
|
||||
setPosition(null);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMouseMove();
|
||||
unsubDragEnter();
|
||||
};
|
||||
}, [inactive]);
|
||||
|
||||
if (inactive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
const tooltipWidth = 200;
|
||||
const correctXPosition = (x: number | undefined) => {
|
||||
if (!x) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const newX = x;
|
||||
|
||||
if (newX + tooltipWidth > screenWidth) {
|
||||
return screenWidth - tooltipWidth;
|
||||
}
|
||||
return newX;
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
left: correctXPosition(position?.x),
|
||||
zIndex: 1000,
|
||||
width: tooltipWidth,
|
||||
}}
|
||||
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.time}</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.visitorCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Portal.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getCountReport, getReport } from './realtime-live-histogram';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -17,11 +16,15 @@ const RealtimeReloader = ({ projectId }: Props) => {
|
||||
if (!document.hidden) {
|
||||
client.refetchQueries(trpc.realtime.pathFilter());
|
||||
client.refetchQueries(
|
||||
trpc.chart.chart.queryFilter(getReport(projectId)),
|
||||
trpc.overview.liveData.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.chart.chart.queryFilter(getCountReport(projectId)),
|
||||
trpc.realtime.activeSessions.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.referrals.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId }));
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRef, useState } from 'react';
|
||||
import type { AxisDomain } from 'recharts/types/util/types';
|
||||
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
export const AXIS_FONT_PROPS = {
|
||||
fontSize: 8,
|
||||
className: 'font-mono',
|
||||
@@ -69,9 +68,11 @@ export const useXAxisProps = (
|
||||
interval: 'auto',
|
||||
},
|
||||
) => {
|
||||
const formatDate = useFormatDateInterval(
|
||||
interval === 'auto' ? 'day' : interval,
|
||||
);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval: interval === 'auto' ? 'day' : interval,
|
||||
short: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...X_AXIS_STYLE_PROPS,
|
||||
height: hide ? 0 : X_AXIS_STYLE_PROPS.height,
|
||||
|
||||
@@ -62,7 +62,10 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
|
||||
const {
|
||||
report: { interval, unit },
|
||||
} = useReportChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
||||
@@ -40,7 +40,10 @@ export function ReportTable({
|
||||
const number = useNumber();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: true,
|
||||
});
|
||||
|
||||
function handleChange(name: string, checked: boolean) {
|
||||
setVisibleSeries((prev) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
@@ -10,8 +12,6 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
@@ -171,7 +171,10 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
}
|
||||
|
||||
const { date } = data[0];
|
||||
const formatDate = useFormatDateInterval(context.interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval: context.interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -26,6 +26,7 @@ const validator = zProject.pick({
|
||||
domain: true,
|
||||
cors: true,
|
||||
crossDomain: true,
|
||||
allowUnsafeRevenueTracking: true,
|
||||
});
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
@@ -39,6 +40,7 @@ export default function EditProjectDetails({ project }: Props) {
|
||||
domain: project.domain,
|
||||
cors: project.cors,
|
||||
crossDomain: project.crossDomain,
|
||||
allowUnsafeRevenueTracking: project.allowUnsafeRevenueTracking,
|
||||
},
|
||||
});
|
||||
const trpc = useTRPC();
|
||||
@@ -155,22 +157,45 @@ export default function EditProjectDetails({ project }: Props) {
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<WithLabel label="Cross domain support" className="mt-4">
|
||||
<CheckboxInput
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
>
|
||||
<div>Enable cross domain support</div>
|
||||
<div className="font-normal text-muted-foreground">
|
||||
This will let you track users across multiple domains
|
||||
</div>
|
||||
</CheckboxInput>
|
||||
</WithLabel>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AnimateHeight>
|
||||
|
||||
<Controller
|
||||
name="allowUnsafeRevenueTracking"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<WithLabel label="Revenue tracking">
|
||||
<CheckboxInput
|
||||
className="mt-4"
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
>
|
||||
<div>Enable cross domain support</div>
|
||||
<div>Allow "unsafe" revenue tracking</div>
|
||||
<div className="font-normal text-muted-foreground">
|
||||
This will let you track users across multiple domains
|
||||
With this enabled, you can track revenue from client code.
|
||||
</div>
|
||||
</CheckboxInput>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AnimateHeight>
|
||||
</WithLabel>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={mutation.isPending}
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import React from 'react';
|
||||
|
||||
export type ColumnPriority = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
|
||||
export interface ColumnResponsive {
|
||||
/**
|
||||
* Priority determines the order columns are hidden.
|
||||
* Lower numbers = higher priority (hidden last).
|
||||
* Higher numbers = lower priority (hidden first).
|
||||
* Default: 5 (medium priority)
|
||||
*/
|
||||
priority?: ColumnPriority;
|
||||
/**
|
||||
* Minimum container width (in pixels) at which this column should be visible.
|
||||
* If not specified, uses priority-based breakpoints.
|
||||
*/
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export interface Props<T> {
|
||||
columns: {
|
||||
@@ -6,6 +24,11 @@ export interface Props<T> {
|
||||
render: (item: T, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
width: string;
|
||||
/**
|
||||
* Responsive settings for this column.
|
||||
* If not provided, column is always visible.
|
||||
*/
|
||||
responsive?: ColumnResponsive;
|
||||
}[];
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
@@ -33,6 +56,44 @@ export const WidgetTableHead = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates container query class based on priority.
|
||||
* Lower priority numbers = hidden at smaller widths.
|
||||
* Priority 1 = always visible (highest priority)
|
||||
* Priority 10 = hidden first (lowest priority)
|
||||
*/
|
||||
function getResponsiveClass(priority: ColumnPriority): string {
|
||||
// Priority 1 = always visible (no hiding)
|
||||
if (priority === 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Columns will be hidden via CSS container queries
|
||||
// Return empty string - hiding is handled by CSS
|
||||
return '';
|
||||
}
|
||||
|
||||
function getResponsiveStyle(
|
||||
priority: ColumnPriority,
|
||||
): React.CSSProperties | undefined {
|
||||
if (priority === 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const minWidth = (priority - 1) * 100 + 100;
|
||||
return {
|
||||
// Use CSS custom property for container query
|
||||
// Will be handled by inline style with container query
|
||||
} as React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates container query class based on custom min-width.
|
||||
*/
|
||||
function getMinWidthClass(minWidth: number): string {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
export function WidgetTable<T>({
|
||||
className,
|
||||
columns,
|
||||
@@ -49,29 +110,91 @@ export function WidgetTable<T>({
|
||||
.join(' ')}`
|
||||
: '1fr';
|
||||
|
||||
const containerId = React.useMemo(
|
||||
() => `widget-table-${Math.random().toString(36).substring(7)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
// Generate CSS for container queries
|
||||
const containerQueryStyles = React.useMemo(() => {
|
||||
const styles: string[] = [];
|
||||
|
||||
columns.forEach((column) => {
|
||||
if (
|
||||
column.responsive?.priority !== undefined &&
|
||||
column.responsive.priority > 1
|
||||
) {
|
||||
// Breakpoints - Priority 2 = 150px, Priority 3 = 250px, etc.
|
||||
// Less aggressive: columns show at smaller container widths
|
||||
const minWidth = (column.responsive.priority - 1) * 100 + 50;
|
||||
// Hide by default by collapsing width and hiding content
|
||||
// Keep in grid flow but take up minimal space
|
||||
styles.push(
|
||||
`.${containerId} .cell[data-priority="${column.responsive.priority}"] { min-width: 0; max-width: 0; padding-left: 0; padding-right: 0; overflow: hidden; visibility: hidden; }`,
|
||||
`@container (min-width: ${minWidth}px) { .${containerId} .cell[data-priority="${column.responsive.priority}"] { min-width: revert; max-width: revert; padding-left: revert; padding-right: 0.5rem; overflow: revert; visibility: visible !important; } }`,
|
||||
);
|
||||
} else if (column.responsive?.minWidth !== undefined) {
|
||||
styles.push(
|
||||
`.${containerId} .cell[data-min-width="${column.responsive.minWidth}"] { min-width: 0; max-width: 0; padding-left: 0; padding-right: 0; overflow: hidden; visibility: hidden; }`,
|
||||
`@container (min-width: ${column.responsive.minWidth}px) { .${containerId} .cell[data-min-width="${column.responsive.minWidth}"] { min-width: revert; max-width: revert; padding-left: revert; padding-right: 0.5rem; overflow: revert; visibility: visible !important; } }`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure last visible cell always has padding-right
|
||||
styles.push(
|
||||
`.${containerId} .cell:last-child { padding-right: 1rem !important; }`,
|
||||
);
|
||||
|
||||
return styles.length > 0 ? <style>{styles.join('\n')}</style> : null;
|
||||
}, [columns, containerId]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn('w-full', className, containerId)}
|
||||
style={{ containerType: 'inline-size' }}
|
||||
>
|
||||
{containerQueryStyles}
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn('grid border-b border-border head', columnClassName)}
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
{columns.map((column, colIndex) => {
|
||||
const responsiveClass =
|
||||
column.responsive?.priority !== undefined
|
||||
? getResponsiveClass(column.responsive.priority)
|
||||
: column.responsive?.minWidth !== undefined
|
||||
? getMinWidthClass(column.responsive.minWidth)
|
||||
: '';
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (column.responsive?.priority !== undefined) {
|
||||
dataAttrs['data-priority'] = String(column.responsive.priority);
|
||||
}
|
||||
if (column.responsive?.minWidth !== undefined) {
|
||||
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
responsiveClass,
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
{...dataAttrs}
|
||||
>
|
||||
{column.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -89,24 +212,47 @@ export function WidgetTable<T>({
|
||||
className="grid h-8 items-center"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'px-2 relative cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
column.width === 'w-full' && 'w-full min-w-0',
|
||||
)}
|
||||
style={
|
||||
column.width !== 'w-full' ? { width: column.width } : {}
|
||||
}
|
||||
>
|
||||
{column.render(item, index)}
|
||||
</div>
|
||||
))}
|
||||
{columns.map((column, colIndex) => {
|
||||
const responsiveClass =
|
||||
column.responsive?.priority !== undefined
|
||||
? getResponsiveClass(column.responsive.priority)
|
||||
: column.responsive?.minWidth !== undefined
|
||||
? getMinWidthClass(column.responsive.minWidth)
|
||||
: '';
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (column.responsive?.priority !== undefined) {
|
||||
dataAttrs['data-priority'] = String(
|
||||
column.responsive.priority,
|
||||
);
|
||||
}
|
||||
if (column.responsive?.minWidth !== undefined) {
|
||||
dataAttrs['data-min-width'] = String(
|
||||
column.responsive.minWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'px-2 relative cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
column.width === 'w-full' && 'w-full min-w-0',
|
||||
responsiveClass,
|
||||
)}
|
||||
style={
|
||||
column.width !== 'w-full' ? { width: column.width } : {}
|
||||
}
|
||||
{...dataAttrs}
|
||||
>
|
||||
{column.render(item, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
export function formatDateInterval(interval: IInterval, date: Date): string {
|
||||
export function formatDateInterval(options: {
|
||||
interval: IInterval;
|
||||
date: Date;
|
||||
short: boolean;
|
||||
}): string {
|
||||
const { interval, date, short } = options;
|
||||
try {
|
||||
if (interval === 'hour' || interval === 'minute') {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
...(!short
|
||||
? {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}
|
||||
: {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
@@ -35,10 +46,13 @@ export function formatDateInterval(interval: IInterval, date: Date): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function useFormatDateInterval(interval: IInterval) {
|
||||
export function useFormatDateInterval(options: {
|
||||
interval: IInterval;
|
||||
short: boolean;
|
||||
}) {
|
||||
return (date: Date | string) =>
|
||||
formatDateInterval(
|
||||
interval,
|
||||
typeof date === 'string' ? new Date(date) : date,
|
||||
);
|
||||
formatDateInterval({
|
||||
...options,
|
||||
date: typeof date === 'string' ? new Date(date) : date,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +33,35 @@ export const shortNumber =
|
||||
|
||||
export const formatCurrency =
|
||||
(locale: string) =>
|
||||
(amount: number, currency = 'USD') => {
|
||||
(
|
||||
amount: number,
|
||||
options?: {
|
||||
currency?: string;
|
||||
short?: boolean;
|
||||
},
|
||||
) => {
|
||||
const short = options?.short ?? false;
|
||||
const currency = options?.currency ?? 'USD';
|
||||
if (short) {
|
||||
// Use compact notation for short format (e.g., "73K $")
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const formatted = formatter.format(amount);
|
||||
// Get currency symbol
|
||||
const currencyFormatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
const parts = currencyFormatter.formatToParts(0);
|
||||
const symbol =
|
||||
parts.find((part) => part.type === 'currency')?.value || '$';
|
||||
return `${formatted} ${symbol}`;
|
||||
}
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
@@ -13,6 +14,15 @@ export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle(PAGE_TITLES.PROFILE_EVENTS),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function Component() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProfileCharts } from '@/components/profiles/profile-charts';
|
||||
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
|
||||
import { ProfileProperties } from '@/components/profiles/profile-properties';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
@@ -44,6 +45,15 @@ export const Route = createFileRoute(
|
||||
]);
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle(PAGE_TITLES.PROFILE_DETAILS),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function Component() {
|
||||
|
||||
@@ -80,7 +80,6 @@ export const PAGE_TITLES = {
|
||||
DASHBOARD: 'Dashboard',
|
||||
EVENTS: 'Events',
|
||||
SESSIONS: 'Sessions',
|
||||
PROFILES: 'Profiles',
|
||||
PAGES: 'Pages',
|
||||
REPORTS: 'Reports',
|
||||
NOTIFICATIONS: 'Notifications',
|
||||
@@ -91,6 +90,10 @@ export const PAGE_TITLES = {
|
||||
CHAT: 'AI Assistant',
|
||||
REALTIME: 'Realtime',
|
||||
REFERENCES: 'References',
|
||||
// Profiles
|
||||
PROFILES: 'Profiles',
|
||||
PROFILE_EVENTS: 'Profile events',
|
||||
PROFILE_DETAILS: 'Profile details',
|
||||
|
||||
// Sub-sections
|
||||
CONVERSIONS: 'Conversions',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getHasFunnelRules,
|
||||
getNotificationRulesByProjectId,
|
||||
sessionBuffer,
|
||||
transformSessionToEvent,
|
||||
} from '@openpanel/db';
|
||||
import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue';
|
||||
|
||||
@@ -31,7 +32,7 @@ async function getSessionEvents({
|
||||
sessionId: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
}): Promise<ReturnType<typeof getEvents>> {
|
||||
}): Promise<IServiceEvent[]> {
|
||||
const sql = `
|
||||
SELECT * FROM ${TABLE_NAMES.events}
|
||||
WHERE
|
||||
@@ -42,16 +43,18 @@ async function getSessionEvents({
|
||||
`;
|
||||
|
||||
const [lastScreenView, eventsInDb] = await Promise.all([
|
||||
eventBuffer.getLastScreenView({
|
||||
projectId,
|
||||
sessionBuffer.getExistingSession({
|
||||
sessionId,
|
||||
}),
|
||||
getEvents(sql),
|
||||
]);
|
||||
|
||||
// sort last inserted first
|
||||
return [lastScreenView, ...eventsInDb]
|
||||
.filter((event): event is IServiceEvent => !!event)
|
||||
return [
|
||||
lastScreenView ? transformSessionToEvent(lastScreenView) : null,
|
||||
...eventsInDb,
|
||||
]
|
||||
.flatMap((event) => (event ? [event] : []))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
@@ -69,7 +72,9 @@ export async function createSessionEnd(
|
||||
|
||||
logger.debug('Processing session end job');
|
||||
|
||||
const session = await sessionBuffer.getExistingSession(payload.sessionId);
|
||||
const session = await sessionBuffer.getExistingSession({
|
||||
sessionId: payload.sessionId,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found');
|
||||
@@ -86,26 +91,21 @@ export async function createSessionEnd(
|
||||
});
|
||||
}
|
||||
|
||||
const lastScreenView = await eventBuffer.getLastScreenView({
|
||||
projectId: payload.projectId,
|
||||
sessionId: payload.sessionId,
|
||||
});
|
||||
|
||||
// Create session end event
|
||||
return createEvent({
|
||||
...payload,
|
||||
properties: {
|
||||
...payload.properties,
|
||||
...(lastScreenView?.properties ?? {}),
|
||||
...(session?.properties ?? {}),
|
||||
__bounce: session.is_bounce,
|
||||
},
|
||||
name: 'session_end',
|
||||
duration: session.duration ?? 0,
|
||||
path: lastScreenView?.path ?? '',
|
||||
path: session.exit_path ?? '',
|
||||
createdAt: new Date(
|
||||
convertClickhouseDateToJs(session.ended_at).getTime() + 100,
|
||||
convertClickhouseDateToJs(session.ended_at).getTime() + 1000,
|
||||
),
|
||||
profileId: lastScreenView?.profileId || payload.profileId,
|
||||
profileId: session.profile_id || payload.profileId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
|
||||
import {
|
||||
checkNotificationRulesForEvent,
|
||||
createEvent,
|
||||
eventBuffer,
|
||||
sessionBuffer,
|
||||
} from '@openpanel/db';
|
||||
import type { ILogger } from '@openpanel/logger';
|
||||
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
|
||||
import * as R from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp'];
|
||||
const GLOBAL_PROPERTIES = ['__path', '__referrer', '__timestamp', '__revenue'];
|
||||
|
||||
// This function will merge two objects.
|
||||
// First it will strip '' and undefined/null from B
|
||||
@@ -41,6 +41,17 @@ async function createEventAndNotify(
|
||||
return event;
|
||||
}
|
||||
|
||||
const parseRevenue = (revenue: unknown): number | undefined => {
|
||||
if (!revenue) return undefined;
|
||||
if (typeof revenue === 'number') return revenue;
|
||||
if (typeof revenue === 'string') {
|
||||
const parsed = Number.parseFloat(revenue);
|
||||
if (Number.isNaN(parsed)) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export async function incomingEvent(
|
||||
jobPayload: EventsQueuePayloadIncomingEvent['payload'],
|
||||
) {
|
||||
@@ -115,12 +126,16 @@ export async function incomingEvent(
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
revenue:
|
||||
body.name === 'revenue' && '__revenue' in properties
|
||||
? parseRevenue(properties.__revenue)
|
||||
: undefined,
|
||||
} as const;
|
||||
|
||||
// if timestamp is from the past we dont want to create a new session
|
||||
if (uaInfo.isServer || isTimestampFromThePast) {
|
||||
const screenView = profileId
|
||||
? await eventBuffer.getLastScreenView({
|
||||
const session = profileId
|
||||
? await sessionBuffer.getExistingSession({
|
||||
profileId,
|
||||
projectId,
|
||||
})
|
||||
@@ -128,25 +143,25 @@ export async function incomingEvent(
|
||||
|
||||
const payload = {
|
||||
...baseEvent,
|
||||
deviceId: screenView?.deviceId ?? '',
|
||||
sessionId: screenView?.sessionId ?? '',
|
||||
referrer: screenView?.referrer ?? undefined,
|
||||
referrerName: screenView?.referrerName ?? undefined,
|
||||
referrerType: screenView?.referrerType ?? undefined,
|
||||
path: screenView?.path ?? baseEvent.path,
|
||||
os: screenView?.os ?? baseEvent.os,
|
||||
osVersion: screenView?.osVersion ?? baseEvent.osVersion,
|
||||
browserVersion: screenView?.browserVersion ?? baseEvent.browserVersion,
|
||||
browser: screenView?.browser ?? baseEvent.browser,
|
||||
device: screenView?.device ?? baseEvent.device,
|
||||
brand: screenView?.brand ?? baseEvent.brand,
|
||||
model: screenView?.model ?? baseEvent.model,
|
||||
city: screenView?.city ?? baseEvent.city,
|
||||
country: screenView?.country ?? baseEvent.country,
|
||||
region: screenView?.region ?? baseEvent.region,
|
||||
longitude: screenView?.longitude ?? baseEvent.longitude,
|
||||
latitude: screenView?.latitude ?? baseEvent.latitude,
|
||||
origin: screenView?.origin ?? baseEvent.origin,
|
||||
deviceId: session?.device_id ?? '',
|
||||
sessionId: session?.id ?? '',
|
||||
referrer: session?.referrer ?? undefined,
|
||||
referrerName: session?.referrer_name ?? undefined,
|
||||
referrerType: session?.referrer_type ?? undefined,
|
||||
path: session?.exit_path ?? baseEvent.path,
|
||||
origin: session?.exit_origin ?? baseEvent.origin,
|
||||
os: session?.os ?? baseEvent.os,
|
||||
osVersion: session?.os_version ?? baseEvent.osVersion,
|
||||
browserVersion: session?.browser_version ?? baseEvent.browserVersion,
|
||||
browser: session?.browser ?? baseEvent.browser,
|
||||
device: session?.device ?? baseEvent.device,
|
||||
brand: session?.brand ?? baseEvent.brand,
|
||||
model: session?.model ?? baseEvent.model,
|
||||
city: session?.city ?? baseEvent.city,
|
||||
country: session?.country ?? baseEvent.country,
|
||||
region: session?.region ?? baseEvent.region,
|
||||
longitude: session?.longitude ?? baseEvent.longitude,
|
||||
latitude: session?.latitude ?? baseEvent.latitude,
|
||||
};
|
||||
|
||||
return createEventAndNotify(payload as IServiceEvent, logger);
|
||||
@@ -160,8 +175,7 @@ export async function incomingEvent(
|
||||
});
|
||||
|
||||
const lastScreenView = sessionEnd
|
||||
? await eventBuffer.getLastScreenView({
|
||||
projectId,
|
||||
? await sessionBuffer.getExistingSession({
|
||||
sessionId: sessionEnd.sessionId,
|
||||
})
|
||||
: null;
|
||||
@@ -173,8 +187,8 @@ export async function incomingEvent(
|
||||
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
|
||||
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
|
||||
// if the path is not set, use the last screen view path
|
||||
path: baseEvent.path || lastScreenView?.path || '',
|
||||
origin: baseEvent.origin || lastScreenView?.origin || '',
|
||||
path: baseEvent.path || lastScreenView?.exit_path || '',
|
||||
origin: baseEvent.origin || lastScreenView?.exit_origin || '',
|
||||
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
|
||||
|
||||
if (!sessionEnd) {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { type IServiceEvent, createEvent } from '@openpanel/db';
|
||||
import {
|
||||
type IClickhouseSession,
|
||||
type IServiceEvent,
|
||||
type IServiceSession,
|
||||
createEvent,
|
||||
formatClickhouseDate,
|
||||
sessionBuffer,
|
||||
} from '@openpanel/db';
|
||||
import { eventBuffer } from '@openpanel/db';
|
||||
import {
|
||||
type EventsQueuePayloadIncomingEvent,
|
||||
@@ -14,10 +21,9 @@ vi.mock('@openpanel/db', async () => {
|
||||
return {
|
||||
...actual,
|
||||
createEvent: vi.fn(),
|
||||
getLastScreenView: vi.fn(),
|
||||
checkNotificationRulesForEvent: vi.fn().mockResolvedValue(true),
|
||||
eventBuffer: {
|
||||
getLastScreenView: vi.fn(),
|
||||
sessionBuffer: {
|
||||
getExistingSession: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -106,6 +112,7 @@ describe('incomingEvent', () => {
|
||||
country: 'US',
|
||||
city: 'New York',
|
||||
region: 'NY',
|
||||
revenue: undefined,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
os: 'Windows',
|
||||
@@ -210,6 +217,7 @@ describe('incomingEvent', () => {
|
||||
country: 'US',
|
||||
city: 'New York',
|
||||
region: 'NY',
|
||||
revenue: undefined,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
os: 'Windows',
|
||||
@@ -278,10 +286,47 @@ describe('incomingEvent', () => {
|
||||
referrerType: 'search',
|
||||
};
|
||||
|
||||
// Mock the eventBuffer.getLastScreenView method
|
||||
vi.mocked(eventBuffer.getLastScreenView).mockResolvedValueOnce(
|
||||
mockLastScreenView as IServiceEvent,
|
||||
);
|
||||
vi.mocked(sessionBuffer.getExistingSession).mockResolvedValueOnce({
|
||||
id: 'last-session-456',
|
||||
event_count: 0,
|
||||
screen_view_count: 0,
|
||||
entry_path: '/last-path',
|
||||
entry_origin: 'https://example.com',
|
||||
exit_path: '/last-path',
|
||||
exit_origin: 'https://example.com',
|
||||
created_at: formatClickhouseDate(timestamp),
|
||||
ended_at: formatClickhouseDate(timestamp),
|
||||
os: 'iOS',
|
||||
os_version: '15.0',
|
||||
browser: 'Safari',
|
||||
browser_version: '15.0',
|
||||
device: 'mobile',
|
||||
brand: 'Apple',
|
||||
model: 'iPhone',
|
||||
country: 'CA',
|
||||
region: 'ON',
|
||||
city: 'Toronto',
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
duration: 0,
|
||||
referrer: 'https://google.com',
|
||||
referrer_name: 'Google',
|
||||
referrer_type: 'search',
|
||||
is_bounce: false,
|
||||
utm_term: '',
|
||||
utm_source: '',
|
||||
utm_campaign: '',
|
||||
utm_content: '',
|
||||
utm_medium: '',
|
||||
revenue: 0,
|
||||
properties: {},
|
||||
project_id: projectId,
|
||||
device_id: 'last-device-123',
|
||||
profile_id: 'profile-123',
|
||||
screen_views: [],
|
||||
sign: 1,
|
||||
version: 1,
|
||||
} satisfies IClickhouseSession);
|
||||
|
||||
await incomingEvent(jobData);
|
||||
|
||||
@@ -317,6 +362,7 @@ describe('incomingEvent', () => {
|
||||
referrerType: 'search',
|
||||
sdkName: 'server',
|
||||
sdkVersion: '1.0.0',
|
||||
revenue: undefined,
|
||||
});
|
||||
|
||||
expect(sessionsQueue.add).not.toHaveBeenCalled();
|
||||
@@ -345,9 +391,6 @@ describe('incomingEvent', () => {
|
||||
uaInfo: uaInfoServer,
|
||||
};
|
||||
|
||||
// Mock getLastScreenView to return null
|
||||
vi.mocked(eventBuffer.getLastScreenView).mockResolvedValueOnce(null);
|
||||
|
||||
await incomingEvent(jobData);
|
||||
|
||||
expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({
|
||||
@@ -365,6 +408,7 @@ describe('incomingEvent', () => {
|
||||
country: 'US',
|
||||
city: 'New York',
|
||||
region: 'NY',
|
||||
revenue: undefined,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
os: '',
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
CREATE DATABASE IF NOT EXISTS openpanel;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self_hosting (
|
||||
`created_at` Date,
|
||||
`domain` String,
|
||||
`count` UInt64
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (domain, created_at);
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
`id` UUID DEFAULT generateUUIDv4(),
|
||||
`name` LowCardinality(String),
|
||||
`sdk_name` LowCardinality(String),
|
||||
`sdk_version` LowCardinality(String),
|
||||
`device_id` String CODEC(ZSTD(3)),
|
||||
`profile_id` String CODEC(ZSTD(3)),
|
||||
`project_id` String CODEC(ZSTD(3)),
|
||||
`session_id` String CODEC(LZ4),
|
||||
`path` String CODEC(ZSTD(3)),
|
||||
`origin` String CODEC(ZSTD(3)),
|
||||
`referrer` String CODEC(ZSTD(3)),
|
||||
`referrer_name` String CODEC(ZSTD(3)),
|
||||
`referrer_type` LowCardinality(String),
|
||||
`duration` UInt64 CODEC(Delta(4), LZ4),
|
||||
`properties` Map(String, String) CODEC(ZSTD(3)),
|
||||
`created_at` DateTime64(3) CODEC(DoubleDelta, ZSTD(3)),
|
||||
`country` LowCardinality(FixedString(2)),
|
||||
`city` String,
|
||||
`region` LowCardinality(String),
|
||||
`longitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`latitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`os` LowCardinality(String),
|
||||
`os_version` LowCardinality(String),
|
||||
`browser` LowCardinality(String),
|
||||
`browser_version` LowCardinality(String),
|
||||
`device` LowCardinality(String),
|
||||
`brand` LowCardinality(String),
|
||||
`model` LowCardinality(String),
|
||||
`imported_at` Nullable(DateTime) CODEC(Delta(4), LZ4),
|
||||
INDEX idx_name name TYPE bloom_filter GRANULARITY 1,
|
||||
INDEX idx_properties_bounce properties['__bounce'] TYPE set(3) GRANULARITY 1,
|
||||
INDEX idx_origin origin TYPE bloom_filter(0.05) GRANULARITY 1,
|
||||
INDEX idx_path path TYPE bloom_filter(0.01) GRANULARITY 1
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (project_id, toDate(created_at), profile_id, name)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events_bots (
|
||||
`id` UUID DEFAULT generateUUIDv4(),
|
||||
`project_id` String,
|
||||
`name` String,
|
||||
`type` String,
|
||||
`path` String,
|
||||
`created_at` DateTime64(3)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (project_id, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
`id` String CODEC(ZSTD(3)),
|
||||
`is_external` Bool,
|
||||
`first_name` String CODEC(ZSTD(3)),
|
||||
`last_name` String CODEC(ZSTD(3)),
|
||||
`email` String CODEC(ZSTD(3)),
|
||||
`avatar` String CODEC(ZSTD(3)),
|
||||
`properties` Map(String, String) CODEC(ZSTD(3)),
|
||||
`project_id` String CODEC(ZSTD(3)),
|
||||
`created_at` DateTime64(3) CODEC(Delta(4), LZ4),
|
||||
INDEX idx_first_name first_name TYPE bloom_filter GRANULARITY 1,
|
||||
INDEX idx_last_name last_name TYPE bloom_filter GRANULARITY 1,
|
||||
INDEX idx_email email TYPE bloom_filter GRANULARITY 1
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(created_at)
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (project_id, id)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_aliases (
|
||||
`project_id` String,
|
||||
`profile_id` String,
|
||||
`alias` String,
|
||||
`created_at` DateTime
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (project_id, profile_id, alias, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS dau_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMMDD(date)
|
||||
ORDER BY (project_id, date)
|
||||
AS SELECT
|
||||
toDate(created_at) as date,
|
||||
uniqState(profile_id) as profile_id,
|
||||
project_id
|
||||
FROM events
|
||||
GROUP BY date, project_id;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS cohort_events_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (project_id, name, created_at, profile_id)
|
||||
AS SELECT
|
||||
project_id,
|
||||
name,
|
||||
toDate(created_at) AS created_at,
|
||||
profile_id,
|
||||
COUNT() AS event_count
|
||||
FROM events
|
||||
WHERE profile_id != device_id
|
||||
GROUP BY project_id, name, created_at, profile_id;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS distinct_event_names_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (project_id, name, created_at)
|
||||
AS SELECT
|
||||
project_id,
|
||||
name,
|
||||
max(created_at) AS created_at,
|
||||
count() AS event_count
|
||||
FROM events
|
||||
GROUP BY project_id, name;
|
||||
|
||||
---
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS event_property_values_mv
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (project_id, name, property_key, property_value)
|
||||
AS SELECT
|
||||
project_id,
|
||||
name,
|
||||
key_value.keys as property_key,
|
||||
key_value.values as property_value,
|
||||
created_at
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
name,
|
||||
untuple(arrayJoin(properties)) as key_value,
|
||||
max(created_at) as created_at
|
||||
FROM events
|
||||
GROUP BY project_id, name, key_value
|
||||
)
|
||||
WHERE property_value != ''
|
||||
AND property_key != ''
|
||||
AND property_key NOT IN ('__duration_from', '__properties_from')
|
||||
GROUP BY project_id, name, property_key, property_value, created_at;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS events_imports_replicated ON CLUSTER '{cluster}' (
|
||||
`id` UUID DEFAULT generateUUIDv4(),
|
||||
`name` LowCardinality(String),
|
||||
`sdk_name` LowCardinality(String),
|
||||
`sdk_version` LowCardinality(String),
|
||||
`device_id` String CODEC(ZSTD(3)),
|
||||
`profile_id` String CODEC(ZSTD(3)),
|
||||
`project_id` String CODEC(ZSTD(3)),
|
||||
`session_id` String CODEC(LZ4),
|
||||
`path` String CODEC(ZSTD(3)),
|
||||
`origin` String CODEC(ZSTD(3)),
|
||||
`referrer` String CODEC(ZSTD(3)),
|
||||
`referrer_name` String CODEC(ZSTD(3)),
|
||||
`referrer_type` LowCardinality(String),
|
||||
`duration` UInt64 CODEC(Delta(4), LZ4),
|
||||
`properties` Map(String, String) CODEC(ZSTD(3)),
|
||||
`created_at` DateTime64(3) CODEC(DoubleDelta, ZSTD(3)),
|
||||
`country` LowCardinality(FixedString(2)),
|
||||
`city` String,
|
||||
`region` LowCardinality(String),
|
||||
`longitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`latitude` Nullable(Float32) CODEC(Gorilla, LZ4),
|
||||
`os` LowCardinality(String),
|
||||
`os_version` LowCardinality(String),
|
||||
`browser` LowCardinality(String),
|
||||
`browser_version` LowCardinality(String),
|
||||
`device` LowCardinality(String),
|
||||
`brand` LowCardinality(String),
|
||||
`model` LowCardinality(String),
|
||||
`imported_at` Nullable(DateTime) CODEC(Delta(4), LZ4),
|
||||
`import_id` String CODEC(ZSTD(3)),
|
||||
`import_status` LowCardinality(String) DEFAULT 'pending',
|
||||
`imported_at_meta` DateTime DEFAULT now()
|
||||
)
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/{installation}/{cluster}/tables/{shard}/openpanel/v1/{table}', '{replica}')
|
||||
PARTITION BY toYYYYMM(imported_at_meta)
|
||||
ORDER BY (import_id, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
---
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events_imports ON CLUSTER '{cluster}' AS events_imports_replicated
|
||||
ENGINE = Distributed('{cluster}', currentDatabase(), events_imports_replicated, cityHash64(import_id));
|
||||
|
||||
---
|
||||
|
||||
ALTER TABLE events_imports_replicated ON CLUSTER '{cluster}' MODIFY TTL imported_at_meta + INTERVAL 7 DAY;
|
||||
36
packages/db/code-migrations/6-add-revenue-column.ts
Normal file
36
packages/db/code-migrations/6-add-revenue-column.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
addColumns,
|
||||
runClickhouseMigrationCommands,
|
||||
} from '../src/clickhouse/migration';
|
||||
import { getIsCluster } from './helpers';
|
||||
|
||||
export async function up() {
|
||||
const isClustered = getIsCluster();
|
||||
|
||||
const sqls: string[] = [
|
||||
...addColumns(
|
||||
'events',
|
||||
['`revenue` UInt64 AFTER `referrer_type`'],
|
||||
isClustered,
|
||||
),
|
||||
];
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__filename.replace('.ts', '.sql')),
|
||||
sqls
|
||||
.map((sql) =>
|
||||
sql
|
||||
.trim()
|
||||
.replace(/;$/, '')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.concat(';'),
|
||||
)
|
||||
.join('\n\n---\n\n'),
|
||||
);
|
||||
|
||||
if (!process.argv.includes('--dry')) {
|
||||
await runClickhouseMigrationCommands(sqls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."projects" ADD COLUMN "allowUnsafeRevenueTracking" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -172,17 +172,18 @@ model Invite {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
eventsCount Int @default(0)
|
||||
types ProjectType[] @default([])
|
||||
domain String?
|
||||
cors String[] @default([])
|
||||
crossDomain Boolean @default(false)
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
eventsCount Int @default(0)
|
||||
types ProjectType[] @default([])
|
||||
domain String?
|
||||
cors String[] @default([])
|
||||
crossDomain Boolean @default(false)
|
||||
allowUnsafeRevenueTracking Boolean @default(false)
|
||||
/// [IPrismaProjectFilters]
|
||||
filters Json @default("[]")
|
||||
filters Json @default("[]")
|
||||
|
||||
clients Client[]
|
||||
reports Report[]
|
||||
|
||||
@@ -28,8 +28,24 @@ export class SessionBuffer extends BaseBuffer {
|
||||
this.redis = getRedisCache();
|
||||
}
|
||||
|
||||
public async getExistingSession(sessionId: string) {
|
||||
const hit = await this.redis.get(`session:${sessionId}`);
|
||||
public async getExistingSession(
|
||||
options:
|
||||
| {
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
},
|
||||
) {
|
||||
let hit: string | null = null;
|
||||
if ('sessionId' in options) {
|
||||
hit = await this.redis.get(`session:${options.sessionId}`);
|
||||
} else {
|
||||
hit = await this.redis.get(
|
||||
`session:${options.projectId}:${options.profileId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
return getSafeJson<IClickhouseSession>(hit);
|
||||
@@ -41,7 +57,9 @@ export class SessionBuffer extends BaseBuffer {
|
||||
async getSession(
|
||||
event: IClickhouseEvent,
|
||||
): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> {
|
||||
const existingSession = await this.getExistingSession(event.session_id);
|
||||
const existingSession = await this.getExistingSession({
|
||||
sessionId: event.session_id,
|
||||
});
|
||||
|
||||
if (existingSession) {
|
||||
const oldSession = assocPath(['sign'], -1, clone(existingSession));
|
||||
@@ -77,7 +95,9 @@ export class SessionBuffer extends BaseBuffer {
|
||||
...(event.properties || {}),
|
||||
...(newSession.properties || {}),
|
||||
});
|
||||
// newSession.revenue += event.properties?.__revenue ?? 0;
|
||||
|
||||
const addedRevenue = event.name === 'revenue' ? (event.revenue ?? 0) : 0;
|
||||
newSession.revenue = (newSession.revenue ?? 0) + addedRevenue;
|
||||
|
||||
if (event.name === 'screen_view' && event.path) {
|
||||
newSession.screen_views.push(event.path);
|
||||
@@ -114,7 +134,7 @@ export class SessionBuffer extends BaseBuffer {
|
||||
entry_origin: event.origin,
|
||||
exit_path: event.path,
|
||||
exit_origin: event.origin,
|
||||
revenue: 0,
|
||||
revenue: event.name === 'revenue' ? (event.revenue ?? 0) : 0,
|
||||
referrer: event.referrer,
|
||||
referrer_name: event.referrer_name,
|
||||
referrer_type: event.referrer_type,
|
||||
@@ -174,6 +194,14 @@ export class SessionBuffer extends BaseBuffer {
|
||||
'EX',
|
||||
60 * 60,
|
||||
);
|
||||
if (newSession.profile_id) {
|
||||
multi.set(
|
||||
`session:${newSession.project_id}:${newSession.profile_id}`,
|
||||
JSON.stringify(newSession),
|
||||
'EX',
|
||||
60 * 60,
|
||||
);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
multi.rpush(this.redisKey, JSON.stringify(session));
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ export function addColumns(
|
||||
isClustered: boolean,
|
||||
): string[] {
|
||||
if (isClustered) {
|
||||
return columns.map(
|
||||
(col) =>
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${col}`,
|
||||
);
|
||||
return columns.flatMap((col) => [
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${col}`,
|
||||
`ALTER TABLE ${tableName} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${col}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return columns.map(
|
||||
@@ -160,10 +160,10 @@ export function dropColumns(
|
||||
isClustered: boolean,
|
||||
): string[] {
|
||||
if (isClustered) {
|
||||
return columnNames.map(
|
||||
(colName) =>
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${colName}`,
|
||||
);
|
||||
return columnNames.flatMap((colName) => [
|
||||
`ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${colName}`,
|
||||
`ALTER TABLE ${tableName} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${colName}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return columnNames.map(
|
||||
|
||||
@@ -174,23 +174,43 @@ export function getChartSql({
|
||||
}
|
||||
|
||||
if (event.segment === 'property_sum' && event.property) {
|
||||
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `sum(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'property_average' && event.property) {
|
||||
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `avg(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'property_max' && event.property) {
|
||||
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `max(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'property_min' && event.property) {
|
||||
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
if (event.property === 'revenue') {
|
||||
sb.select.count = `min(revenue) as count`;
|
||||
sb.where.property = `revenue > 0`;
|
||||
} else {
|
||||
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.segment === 'one_event_per_user') {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getProfilesCached,
|
||||
upsertProfile,
|
||||
} from './profile.service';
|
||||
import type { IClickhouseSession } from './session.service';
|
||||
|
||||
export type IImportedEvent = Omit<
|
||||
IClickhouseEvent,
|
||||
@@ -92,12 +93,62 @@ export interface IClickhouseEvent {
|
||||
imported_at: string | null;
|
||||
sdk_name: string;
|
||||
sdk_version: string;
|
||||
revenue?: number;
|
||||
|
||||
// They do not exist here. Just make ts happy for now
|
||||
profile?: IServiceProfile;
|
||||
meta?: EventMeta;
|
||||
}
|
||||
|
||||
export function transformSessionToEvent(
|
||||
session: IClickhouseSession,
|
||||
): IServiceEvent {
|
||||
return {
|
||||
id: '', // Not used
|
||||
name: 'screen_view',
|
||||
sessionId: session.id,
|
||||
profileId: session.profile_id,
|
||||
path: session.exit_path,
|
||||
origin: session.exit_origin,
|
||||
createdAt: convertClickhouseDateToJs(session.ended_at),
|
||||
referrer: session.referrer,
|
||||
referrerName: session.referrer_name,
|
||||
referrerType: session.referrer_type,
|
||||
os: session.os,
|
||||
osVersion: session.os_version,
|
||||
browser: session.browser,
|
||||
browserVersion: session.browser_version,
|
||||
device: session.device,
|
||||
brand: session.brand,
|
||||
model: session.model,
|
||||
country: session.country,
|
||||
region: session.region,
|
||||
city: session.city,
|
||||
longitude: session.longitude,
|
||||
latitude: session.latitude,
|
||||
projectId: session.project_id,
|
||||
deviceId: session.device_id,
|
||||
duration: 0,
|
||||
revenue: session.revenue,
|
||||
properties: {
|
||||
...session.properties,
|
||||
is_bounce: session.is_bounce,
|
||||
__query: {
|
||||
utm_medium: session.utm_medium,
|
||||
utm_source: session.utm_source,
|
||||
utm_campaign: session.utm_campaign,
|
||||
utm_content: session.utm_content,
|
||||
utm_term: session.utm_term,
|
||||
},
|
||||
},
|
||||
profile: undefined,
|
||||
meta: undefined,
|
||||
importedAt: undefined,
|
||||
sdkName: undefined,
|
||||
sdkVersion: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
return {
|
||||
id: event.id,
|
||||
@@ -131,6 +182,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
|
||||
sdkName: event.sdk_name,
|
||||
sdkVersion: event.sdk_version,
|
||||
profile: event.profile,
|
||||
revenue: event.revenue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +230,7 @@ export interface IServiceEvent {
|
||||
meta: EventMeta | undefined;
|
||||
sdkName: string | undefined;
|
||||
sdkVersion: string | undefined;
|
||||
revenue?: number;
|
||||
}
|
||||
|
||||
type SelectHelper<T> = {
|
||||
@@ -336,6 +389,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
imported_at: null,
|
||||
sdk_name: payload.sdkName ?? '',
|
||||
sdk_version: payload.sdkVersion ?? '',
|
||||
revenue: payload.revenue,
|
||||
};
|
||||
|
||||
const promises = [sessionBuffer.add(event), eventBuffer.add(event)];
|
||||
|
||||
@@ -104,6 +104,7 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
};
|
||||
series: {
|
||||
date: string;
|
||||
@@ -113,6 +114,7 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
}[];
|
||||
}> {
|
||||
const where = this.getRawWhereClause('sessions', filters);
|
||||
@@ -122,6 +124,7 @@ export class OverviewService {
|
||||
.select([
|
||||
`${clix.toStartOf('created_at', interval, timezone)} AS date`,
|
||||
'round((countIf(is_bounce = 1 AND sign = 1) * 100.) / countIf(sign = 1), 2) AS bounce_rate',
|
||||
'sum(revenue * sign) AS total_revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('sign', '=', 1)
|
||||
@@ -165,10 +168,17 @@ export class OverviewService {
|
||||
.from('session_agg')
|
||||
.where('date', '=', rollupDate),
|
||||
)
|
||||
.with(
|
||||
'overall_total_revenue',
|
||||
clix(this.client, timezone)
|
||||
.select(['total_revenue'])
|
||||
.from('session_agg')
|
||||
.where('date', '=', rollupDate),
|
||||
)
|
||||
.with(
|
||||
'daily_stats',
|
||||
clix(this.client, timezone)
|
||||
.select(['date', 'bounce_rate'])
|
||||
.select(['date', 'bounce_rate', 'total_revenue'])
|
||||
.from('session_agg')
|
||||
.where('date', '!=', rollupDate),
|
||||
)
|
||||
@@ -181,9 +191,11 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
overall_unique_visitors: number;
|
||||
overall_total_sessions: number;
|
||||
overall_bounce_rate: number;
|
||||
overall_total_revenue: number;
|
||||
}>([
|
||||
`${clix.toStartOf('e.created_at', interval)} AS date`,
|
||||
'ds.bounce_rate as bounce_rate',
|
||||
@@ -193,9 +205,11 @@ export class OverviewService {
|
||||
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
|
||||
'count(*) AS total_screen_views',
|
||||
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session',
|
||||
'ds.total_revenue AS total_revenue',
|
||||
'(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
|
||||
'(SELECT total_sessions FROM overall_unique_visitors) AS overall_total_sessions',
|
||||
'(SELECT bounce_rate FROM overall_bounce_rate) AS overall_bounce_rate',
|
||||
'(SELECT total_revenue FROM overall_total_revenue) AS overall_total_revenue',
|
||||
])
|
||||
.from(`${TABLE_NAMES.events} AS e`)
|
||||
.leftJoin(
|
||||
@@ -209,7 +223,7 @@ export class OverviewService {
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', filters))
|
||||
.groupBy(['date', 'ds.bounce_rate'])
|
||||
.groupBy(['date', 'ds.bounce_rate', 'ds.total_revenue'])
|
||||
.orderBy('date', 'ASC')
|
||||
.fill(
|
||||
clix.toStartOf(
|
||||
@@ -234,7 +248,8 @@ export class OverviewService {
|
||||
(item) =>
|
||||
item.overall_bounce_rate !== null ||
|
||||
item.overall_total_sessions !== null ||
|
||||
item.overall_unique_visitors !== null,
|
||||
item.overall_unique_visitors !== null ||
|
||||
item.overall_total_revenue !== null,
|
||||
);
|
||||
return {
|
||||
metrics: {
|
||||
@@ -250,12 +265,14 @@ export class OverviewService {
|
||||
views_per_session: average(
|
||||
res.map((item) => item.views_per_session),
|
||||
),
|
||||
total_revenue: anyRowWithData?.overall_total_revenue ?? 0,
|
||||
},
|
||||
series: res.map(
|
||||
omit([
|
||||
'overall_bounce_rate',
|
||||
'overall_unique_visitors',
|
||||
'overall_total_sessions',
|
||||
'overall_total_revenue',
|
||||
]),
|
||||
),
|
||||
};
|
||||
@@ -271,6 +288,7 @@ export class OverviewService {
|
||||
avg_session_duration: number;
|
||||
total_screen_views: number;
|
||||
views_per_session: number;
|
||||
total_revenue: number;
|
||||
}>([
|
||||
`${clix.toStartOf('created_at', interval, timezone)} AS date`,
|
||||
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
|
||||
@@ -280,6 +298,7 @@ export class OverviewService {
|
||||
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
|
||||
'sum(sign * screen_view_count) AS total_screen_views',
|
||||
'round(sum(sign * screen_view_count) * 1.0 / sum(sign), 2) AS views_per_session',
|
||||
'sum(revenue * sign) AS total_revenue',
|
||||
])
|
||||
.from('sessions')
|
||||
.where('created_at', 'BETWEEN', [
|
||||
@@ -320,6 +339,7 @@ export class OverviewService {
|
||||
avg_session_duration: res[0]?.avg_session_duration ?? 0,
|
||||
total_screen_views: res[0]?.total_screen_views ?? 0,
|
||||
views_per_session: res[0]?.views_per_session ?? 0,
|
||||
total_revenue: res[0]?.total_revenue ?? 0,
|
||||
},
|
||||
series: res
|
||||
.slice(1)
|
||||
@@ -394,6 +414,7 @@ export class OverviewService {
|
||||
'entry_path',
|
||||
'entry_origin',
|
||||
'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('sign', '=', 1)
|
||||
@@ -417,6 +438,7 @@ export class OverviewService {
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
'p.title',
|
||||
'p.origin',
|
||||
@@ -424,6 +446,7 @@ export class OverviewService {
|
||||
'p.avg_duration',
|
||||
'p.count as sessions',
|
||||
'b.bounce_rate',
|
||||
'coalesce(b.revenue, 0) as revenue',
|
||||
])
|
||||
.from('page_stats p', false)
|
||||
.leftJoin(
|
||||
@@ -465,12 +488,14 @@ export class OverviewService {
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
`${mode}_origin AS origin`,
|
||||
`${mode}_path AS path`,
|
||||
'round(avg(duration * sign)/1000, 2) as avg_duration',
|
||||
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
|
||||
'sum(sign) as sessions',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('project_id', '=', projectId)
|
||||
@@ -566,12 +591,14 @@ export class OverviewService {
|
||||
sessions: number;
|
||||
bounce_rate: number;
|
||||
avg_session_duration: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
prefixColumn && `${prefixColumn} as prefix`,
|
||||
`nullIf(${column}, '') as name`,
|
||||
'sum(sign) as sessions',
|
||||
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) AS bounce_rate',
|
||||
'round(avgIf(duration, duration > 0 AND sign > 0), 2)/1000 AS avg_session_duration',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions, true)
|
||||
.where('project_id', '=', projectId)
|
||||
|
||||
@@ -28,6 +28,7 @@ export type IProfileMetrics = {
|
||||
avgEventsPerSession: number;
|
||||
conversionEvents: number;
|
||||
avgTimeBetweenSessions: number;
|
||||
revenue: number;
|
||||
};
|
||||
export function getProfileMetrics(profileId: string, projectId: string) {
|
||||
return chQuery<
|
||||
@@ -76,6 +77,9 @@ export function getProfileMetrics(profileId: string, projectId: string) {
|
||||
WHEN (SELECT sessions FROM sessions) <= 1 THEN 0
|
||||
ELSE round(dateDiff('second', (SELECT firstSeen FROM firstSeen), (SELECT lastSeen FROM lastSeen)) / nullIf((SELECT sessions FROM sessions) - 1, 0), 1)
|
||||
END as avgTimeBetweenSessions
|
||||
),
|
||||
revenue AS (
|
||||
SELECT sum(revenue) as revenue FROM ${TABLE_NAMES.events} WHERE name = 'revenue' AND profile_id = ${sqlstring.escape(profileId)} AND project_id = ${sqlstring.escape(projectId)}
|
||||
)
|
||||
SELECT
|
||||
(SELECT lastSeen FROM lastSeen) as lastSeen,
|
||||
@@ -89,7 +93,8 @@ export function getProfileMetrics(profileId: string, projectId: string) {
|
||||
(SELECT bounceRate FROM bounceRate) as bounceRate,
|
||||
(SELECT avgEventsPerSession FROM avgEventsPerSession) as avgEventsPerSession,
|
||||
(SELECT conversionEvents FROM conversionEvents) as conversionEvents,
|
||||
(SELECT avgTimeBetweenSessions FROM avgTimeBetweenSessions) as avgTimeBetweenSessions
|
||||
(SELECT avgTimeBetweenSessions FROM avgTimeBetweenSessions) as avgTimeBetweenSessions,
|
||||
(SELECT revenue FROM revenue) as revenue
|
||||
`)
|
||||
.then((data) => data[0]!)
|
||||
.then((data) => {
|
||||
|
||||
@@ -209,12 +209,27 @@ export function sessionConsistency() {
|
||||
// Since the check probably goes to the primary anyways it will always be true,
|
||||
// Not sure how to check LSN on the actual replica that will be used for the read.
|
||||
if (
|
||||
model !== 'Session' &&
|
||||
isReadOperation(operation) &&
|
||||
sessionId &&
|
||||
(await getCachedWalLsn(sessionId))
|
||||
) {
|
||||
// This will force readReplicas extension to use primary
|
||||
__internalParams.transaction = true;
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_RETRY_DELAY_MS = 50;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const result = await query(args);
|
||||
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If not the last attempt, wait with exponential backoff before retrying
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
const delayMs = INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return query(args);
|
||||
|
||||
@@ -183,6 +183,28 @@ export class OpenPanel {
|
||||
});
|
||||
}
|
||||
|
||||
async revenue(
|
||||
amount: number,
|
||||
properties?: TrackProperties & { deviceId?: string },
|
||||
) {
|
||||
const deviceId = properties?.deviceId;
|
||||
delete properties?.deviceId;
|
||||
return this.track('revenue', {
|
||||
...(properties ?? {}),
|
||||
...(deviceId ? { __deviceId: deviceId } : {}),
|
||||
__revenue: amount,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDeviceId(): Promise<string> {
|
||||
const result = await this.api.fetch<undefined, { deviceId: string }>(
|
||||
'/track/device-id',
|
||||
undefined,
|
||||
{ method: 'GET', keepalive: false },
|
||||
);
|
||||
return result?.deviceId ?? '';
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.profileId = undefined;
|
||||
// should we force a session end here?
|
||||
|
||||
@@ -20,9 +20,15 @@ function toCamelCase(str: string) {
|
||||
);
|
||||
}
|
||||
|
||||
type PendingRevenue = {
|
||||
amount: number;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class OpenPanel extends OpenPanelBase {
|
||||
private lastPath = '';
|
||||
private debounceTimer: any;
|
||||
private pendingRevenues: PendingRevenue[] = [];
|
||||
|
||||
constructor(public options: OpenPanelOptions) {
|
||||
super({
|
||||
@@ -34,6 +40,18 @@ export class OpenPanel extends OpenPanelBase {
|
||||
if (!this.isServer()) {
|
||||
console.log('OpenPanel.dev - Initialized', this.options);
|
||||
|
||||
try {
|
||||
const pending = sessionStorage.getItem('openpanel-pending-revenues');
|
||||
if (pending) {
|
||||
const parsed = JSON.parse(pending);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.pendingRevenues = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.pendingRevenues = [];
|
||||
}
|
||||
|
||||
this.setGlobalProperties({
|
||||
__referrer: document.referrer,
|
||||
});
|
||||
@@ -191,4 +209,33 @@ export class OpenPanel extends OpenPanelBase {
|
||||
__title: document.title,
|
||||
});
|
||||
}
|
||||
|
||||
async flushRevenue() {
|
||||
const promises = this.pendingRevenues.map((pending) =>
|
||||
super.revenue(pending.amount, pending.properties),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
this.clearRevenue();
|
||||
}
|
||||
|
||||
clearRevenue() {
|
||||
this.pendingRevenues = [];
|
||||
if (!this.isServer()) {
|
||||
try {
|
||||
sessionStorage.removeItem('openpanel-pending-revenues');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
pendingRevenue(amount: number, properties?: Record<string, unknown>) {
|
||||
this.pendingRevenues.push({ amount, properties });
|
||||
if (!this.isServer()) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'openpanel-pending-revenues',
|
||||
JSON.stringify(this.pendingRevenues),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/sdks/web/src/types.d.ts
vendored
6
packages/sdks/web/src/types.d.ts
vendored
@@ -8,7 +8,11 @@ type ExposedMethodsNames =
|
||||
| 'alias'
|
||||
| 'increment'
|
||||
| 'decrement'
|
||||
| 'clear';
|
||||
| 'clear'
|
||||
| 'revenue'
|
||||
| 'flushRevenue'
|
||||
| 'clearRevenue'
|
||||
| 'pendingRevenue';
|
||||
|
||||
export type ExposedMethods = {
|
||||
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any
|
||||
|
||||
@@ -60,13 +60,18 @@ export const chartRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { projectId } }) => {
|
||||
const chartPromise = chQuery<{ value: number; date: Date }>(
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const chartPromise = chQuery<{
|
||||
value: number;
|
||||
date: Date;
|
||||
revenue: number;
|
||||
}>(
|
||||
`SELECT
|
||||
uniqHLL12(profile_id) as value,
|
||||
toStartOfDay(created_at) as date
|
||||
toStartOfDay(created_at) as date,
|
||||
sum(revenue * sign) as revenue
|
||||
FROM ${TABLE_NAMES.sessions}
|
||||
WHERE
|
||||
sign = 1 AND
|
||||
project_id = ${sqlstring.escape(projectId)} AND
|
||||
created_at >= now() - interval '3 month'
|
||||
GROUP BY date
|
||||
@@ -74,22 +79,25 @@ export const chartRouter = createTRPCRouter({
|
||||
WITH FILL FROM toStartOfDay(now() - interval '1 month')
|
||||
TO toStartOfDay(now())
|
||||
STEP INTERVAL 1 day
|
||||
SETTINGS session_timezone = '${timezone}'
|
||||
`,
|
||||
);
|
||||
|
||||
const metricsPromise = clix(ch)
|
||||
const metricsPromise = clix(ch, timezone)
|
||||
.select<{
|
||||
months_3: number;
|
||||
months_3_prev: number;
|
||||
month: number;
|
||||
day: number;
|
||||
day_prev: number;
|
||||
revenue: number;
|
||||
}>([
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(3)), profile_id, null)) AS months_3',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(6)) AND created_at < (now() - toIntervalMonth(3)), profile_id, null)) AS months_3_prev',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(1)), profile_id, null)) AS month',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalDay(1)), profile_id, null)) AS day',
|
||||
'uniqHLL12(if(created_at >= (now() - toIntervalDay(2)) AND created_at < (now() - toIntervalDay(1)), profile_id, null)) AS day_prev',
|
||||
'sum(revenue * sign) as revenue',
|
||||
])
|
||||
.from(TABLE_NAMES.sessions)
|
||||
.where('project_id', '=', projectId)
|
||||
@@ -207,6 +215,7 @@ export const chartRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
properties.push(
|
||||
'revenue',
|
||||
'has_profile',
|
||||
'path',
|
||||
'origin',
|
||||
|
||||
@@ -103,7 +103,6 @@ export const overviewRouter = createTRPCRouter({
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', '=', 'session_start')
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'));
|
||||
|
||||
// Get counts per minute for the last 30 minutes
|
||||
@@ -119,7 +118,6 @@ export const overviewRouter = createTRPCRouter({
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', 'IN', ['session_start', 'screen_view'])
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||
.groupBy(['minute'])
|
||||
.orderBy('minute', 'ASC')
|
||||
@@ -138,11 +136,10 @@ export const overviewRouter = createTRPCRouter({
|
||||
}>([
|
||||
`${clix.toStartOf('created_at', 'minute')} as minute`,
|
||||
'referrer_name',
|
||||
'count(*) as count',
|
||||
'uniq(session_id) as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', '=', 'session_start')
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||
.where('referrer_name', '!=', '')
|
||||
.where('referrer_name', 'IS NOT NULL')
|
||||
@@ -154,11 +151,10 @@ export const overviewRouter = createTRPCRouter({
|
||||
const referrersQuery = clix(ch, timezone)
|
||||
.select<{ referrer: string; count: number }>([
|
||||
'referrer_name as referrer',
|
||||
'count(*) as count',
|
||||
'uniq(session_id) as count',
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('name', '=', 'session_start')
|
||||
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||
.where('referrer_name', '!=', '')
|
||||
.where('referrer_name', 'IS NOT NULL')
|
||||
@@ -234,6 +230,7 @@ export const overviewRouter = createTRPCRouter({
|
||||
previous?.metrics.avg_session_duration || null,
|
||||
prev_views_per_session: previous?.metrics.views_per_session || null,
|
||||
prev_total_sessions: previous?.metrics.total_sessions || null,
|
||||
prev_total_revenue: previous?.metrics.total_revenue || null,
|
||||
},
|
||||
series: current.series.map((item, index) => {
|
||||
const prev = previous?.series[index];
|
||||
@@ -246,6 +243,7 @@ export const overviewRouter = createTRPCRouter({
|
||||
prev_avg_session_duration: prev?.avg_session_duration,
|
||||
prev_views_per_session: prev?.views_per_session,
|
||||
prev_total_sessions: prev?.total_sessions,
|
||||
prev_total_revenue: prev?.total_revenue,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ export const projectRouter = createTRPCRouter({
|
||||
input.cors === undefined
|
||||
? undefined
|
||||
: input.cors.map((c) => stripTrailingSlash(c)) || [],
|
||||
allowUnsafeRevenueTracking: input.allowUnsafeRevenueTracking,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
@@ -123,6 +124,7 @@ export const projectRouter = createTRPCRouter({
|
||||
domain: input.domain,
|
||||
cors: input.cors,
|
||||
crossDomain: false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
clients: {
|
||||
create: data,
|
||||
|
||||
@@ -191,7 +191,7 @@ export const cacheMiddleware = (
|
||||
key += JSON.stringify(rawInput).replace(/\"/g, "'");
|
||||
}
|
||||
const cache = await getRedisCache().getJson(key);
|
||||
if (cache) {
|
||||
if (cache && process.env.NODE_ENV === 'production') {
|
||||
return {
|
||||
ok: true,
|
||||
data: cache,
|
||||
|
||||
@@ -379,6 +379,7 @@ export const zProject = z.object({
|
||||
domain: z.string().url().or(z.literal('').or(z.null())),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().default(false),
|
||||
allowUnsafeRevenueTracking: z.boolean().default(false),
|
||||
});
|
||||
export type IProjectEdit = z.infer<typeof zProject>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user