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:
Carl-Gerhard Lindesvärd
2025-11-19 14:27:34 +01:00
committed by GitHub
parent d61cbf6f2c
commit 790801b728
58 changed files with 2191 additions and 23691 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.secrets .secrets
packages/db/src/generated/prisma packages/db/src/generated/prisma
packages/db/code-migrations/*.sql
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt packages/sdk/profileId.txt

View File

@@ -20,7 +20,7 @@ export async function postEvent(
request.body, request.body,
); );
const ip = request.clientIp; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent'];
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);
@@ -30,18 +30,22 @@ export async function postEvent(
} }
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = generateDeviceId({ const currentDeviceId = ua
salt: salts.current, ? generateDeviceId({
origin: projectId, salt: salts.current,
ip, origin: projectId,
ua, ip,
}); ua,
const previousDeviceId = generateDeviceId({ })
salt: salts.previous, : '';
origin: projectId, const previousDeviceId = ua
ip, ? generateDeviceId({
ua, salt: salts.previous,
}); origin: projectId,
ip,
ua,
})
: '';
const uaInfo = parseUserAgent(ua, request.body?.properties); const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer

View File

@@ -21,7 +21,7 @@ export async function updateProfile(
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
const ip = request.clientIp; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent'];
const uaInfo = parseUserAgent(ua, payload.properties); const uaInfo = parseUserAgent(ua, payload.properties);
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);

View File

@@ -6,6 +6,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import type { import type {
DecrementPayload, DecrementPayload,
IdentifyPayload, IdentifyPayload,
@@ -102,7 +103,7 @@ export async function handler(
request.body.payload.properties?.__ip request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string) ? (request.body.payload.properties.__ip as string)
: request.clientIp; : request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent'];
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
if (!projectId) { if (!projectId) {
@@ -115,6 +116,16 @@ export async function handler(
const identity = getIdentity(request.body); const identity = getIdentity(request.body);
const profileId = identity?.profileId; 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 // We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload // 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) { switch (request.body.type) {
case 'track': { case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua const currentDeviceId =
? generateDeviceId({ overrideDeviceId ||
salt: salts.current, (ua
origin: projectId, ? generateDeviceId({
ip, salt: salts.current,
ua, origin: projectId,
}) ip,
: ''; ua,
})
: '');
const previousDeviceId = ua const previousDeviceId = ua
? generateDeviceId({ ? generateDeviceId({
salt: salts.previous, salt: salts.previous,
@@ -370,3 +383,65 @@ async function decrement({
isExternal: true, 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',
});
}

View File

@@ -1,4 +1,4 @@
import { handler } from '@/controllers/track.controller'; import { fetchDeviceId, handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { clientHook } from '@/hooks/client.hook'; 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; export default trackRouter;

View File

@@ -105,6 +105,22 @@ export async function validateSdkRequest(
throw createError('Ingestion: Profile id is blocked by project filter'); 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) { if (client.ignoreCorsAndSecret) {
return client; return client;
} }

View 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>
);
}

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

View File

@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev --port 3001",
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"start": "next start", "start": "next start",
"postinstall": "fumadocs-mdx", "postinstall": "fumadocs-mdx",

View File

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

View File

@@ -1,3 +1,4 @@
import { cn } from '@/utils/cn';
import { createContext, useContext as useBaseContext } from 'react'; import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts'; import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
@@ -21,11 +22,18 @@ export const ChartTooltipHeader = ({
export const ChartTooltipItem = ({ export const ChartTooltipItem = ({
children, children,
color, color,
}: { children: React.ReactNode; color: string }) => { className,
innerClassName,
}: {
children: React.ReactNode;
color: string;
className?: string;
innerClassName?: string;
}) => {
return ( return (
<div className="flex gap-2"> <div className={cn('flex gap-2', className)}>
<div className="w-[3px] rounded-full" style={{ background: color }} /> <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> </div>
); );
}; };

View File

@@ -77,6 +77,15 @@ export const BarShapeBlue = BarWithBorder({
fill: 'rgba(59, 121, 255, 0.4)', 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({ export const BarShapeProps = BarWithBorder({
borderHeight: 2, borderHeight: 2,
border: 'props', border: 'props',

View File

@@ -48,6 +48,10 @@ export const EventIconRecords: Record<
icon: 'ExternalLinkIcon', icon: 'ExternalLinkIcon',
color: 'indigo', color: 'indigo',
}, },
revenue: {
icon: 'DollarSignIcon',
color: 'green',
},
}; };
export const EventIconMapper: Record<string, LucideIcon> = { export const EventIconMapper: Record<string, LucideIcon> = {

View File

@@ -54,7 +54,7 @@ export const EventItem = memo<EventItemProps>(
}} }}
data-slot="inner" data-slot="inner"
className={cn( className={cn(
'col gap-2 flex-1 p-2', 'col gap-1 flex-1 p-2',
// Desktop // Desktop
'@lg:row @lg:items-center', '@lg:row @lg:items-center',
'cursor-pointer', 'cursor-pointer',
@@ -63,7 +63,7 @@ export const EventItem = memo<EventItemProps>(
: 'hover:bg-def-200', : '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 <button
type="button" type="button"
className="transition-transform hover:scale-105" className="transition-transform hover:scale-105"
@@ -77,7 +77,7 @@ export const EventItem = memo<EventItemProps>(
> >
<EventIcon name={event.name} size="sm" meta={event.meta} /> <EventIcon name={event.name} size="sm" meta={event.meta} />
</button> </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' ? ( {event.name === 'screen_view' ? (
<> <>
<span className="text-muted-foreground mr-2">Visit:</span> <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 className="font-medium">{event.name}</span>
</> </>
)} )}
</span> </span>
</div> </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 && ( {event.referrerName && viewOptions.referrerName !== false && (
<Pill <Pill
icon={<SerieIcon className="mr-2" name={event.referrerName} />} icon={<SerieIcon className="mr-2" name={event.referrerName} />}

View File

@@ -108,8 +108,8 @@ function Wrapper({ children, count, icons }: WrapperProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="row gap-2 justify-between"> <div className="row gap-2 justify-between">
<div className="relative mb-1 text-sm font-medium text-muted-foreground"> <div className="relative mb-1 text-xs font-medium text-muted-foreground">
{count} sessions last 30 minutes {count} sessions last 30 min
</div> </div>
<div>{icons}</div> <div>{icons}</div>
</div> </div>

View File

@@ -6,7 +6,7 @@ import { Area, AreaChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date'; import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common'; import { getPreviousMetric } from '@openpanel/common';
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
ChartTooltipContainer, ChartTooltipContainer,
ChartTooltipHeader, ChartTooltipHeader,
@@ -24,12 +24,13 @@ interface MetricCardProps {
data: { data: {
current: number; current: number;
previous?: number; previous?: number;
date: string;
}[]; }[];
metric: { metric: {
current: number; current: number;
previous?: number | null; previous?: number | null;
}; };
unit?: '' | 'date' | 'timeAgo' | 'min' | '%'; unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency';
label: string; label: string;
onClick?: () => void; onClick?: () => void;
active?: boolean; active?: boolean;
@@ -48,9 +49,28 @@ export function OverviewMetricCard({
inverted = false, inverted = false,
isLoading = false, isLoading = false,
}: MetricCardProps) { }: MetricCardProps) {
const [value, setValue] = useState(metric.current); const [currentIndex, setCurrentIndex] = useState<number | null>(null);
const number = useNumber(); const number = useNumber();
const { current, previous } = metric; 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) => { const renderValue = (value: number, unitClassName?: string, short = true) => {
if (unit === 'date') { if (unit === 'date') {
@@ -65,6 +85,11 @@ export function OverviewMetricCard({
return <>{fancyMinutes(value)}</>; return <>{fancyMinutes(value)}</>;
} }
if (unit === 'currency') {
// Revenue is stored in cents, convert to dollars
return <>{number.currency(value / 100)}</>;
}
return ( return (
<> <>
{short ? number.short(value) : number.format(value)} {short ? number.short(value) : number.format(value)}
@@ -81,19 +106,33 @@ export function OverviewMetricCard({
'#93c5fd', // blue '#93c5fd', // blue
); );
return ( const renderTooltip = () => {
<Tooltiper if (currentIndex) {
content={ return (
<span> <span>
{label}:{' '} {formatDate(new Date(data[currentIndex]?.date))}:{' '}
<span className="font-semibold"> <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>
</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 <button
type="button" type="button"
className={cn( className={cn(
@@ -116,9 +155,7 @@ export function OverviewMetricCard({
data={data} data={data}
style={{ marginTop: (height / 4) * 3 }} style={{ marginTop: (height / 4) * 3 }}
onMouseMove={(event) => { onMouseMove={(event) => {
setValue( setCurrentIndex(event.activeTooltipIndex ?? null);
event.activePayload?.[0]?.payload?.current ?? current,
);
}} }}
> >
<defs> <defs>

View File

@@ -2,7 +2,6 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
@@ -13,24 +12,22 @@ import { getPreviousMetric } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last, omit } from 'ramda'; import { last } from 'ramda';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Area,
Bar, Bar,
BarChart,
CartesianGrid, CartesianGrid,
Cell,
ComposedChart, ComposedChart,
Customized, Customized,
Line, Line,
LineChart,
ReferenceLine, ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { useLocalStorage } from 'usehooks-ts';
import { createChartTooltip } from '../charts/chart-tooltip'; import { createChartTooltip } from '../charts/chart-tooltip';
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator'; import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton'; import { Skeleton } from '../skeleton';
@@ -78,6 +75,12 @@ const TITLES = [
unit: 'min', unit: 'min',
inverted: false, inverted: false,
}, },
{
title: 'Revenue',
key: 'total_revenue',
unit: 'currency',
inverted: false,
},
] as const; ] as const;
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
@@ -86,11 +89,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
const trpc = useTRPC(); const trpc = useTRPC();
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
'chartType',
'bars',
);
const activeMetric = TITLES[metric]!; const activeMetric = TITLES[metric]!;
const overviewQuery = useQuery( const overviewQuery = useQuery(
trpc.overview.stats.queryOptions({ trpc.overview.stats.queryOptions({
@@ -125,6 +123,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
}} }}
unit={title.unit} unit={title.unit}
data={data.map((item) => ({ data={data.map((item) => ({
date: item.date,
current: item[title.key], current: item[title.key],
previous: item[`prev_${title.key}`], previous: item[`prev_${title.key}`],
}))} }))}
@@ -136,7 +135,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<div <div
className={cn( 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} /> <OverviewLiveHistogram projectId={projectId} />
@@ -148,32 +147,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<div className="text-sm font-medium text-muted-foreground"> <div className="text-sm font-medium text-muted-foreground">
{activeMetric.title} {activeMetric.title}
</div> </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>
<div className="w-full h-[150px]"> <div className="w-full h-[150px]">
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />} {overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
@@ -181,7 +154,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
activeMetric={activeMetric} activeMetric={activeMetric}
interval={interval} interval={interval}
data={data} data={data}
chartType={chartType}
projectId={projectId} projectId={projectId}
/> />
</div> </div>
@@ -194,18 +166,25 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
RouterOutputs['overview']['stats']['series'][number], RouterOutputs['overview']['stats']['series'][number],
{ {
anyMetric?: boolean;
metric: (typeof TITLES)[number]; metric: (typeof TITLES)[number];
interval: IInterval; interval: IInterval;
} }
>(({ context: { metric, interval }, data: dataArray }) => { >(({ context: { metric, interval, anyMetric }, data: dataArray }) => {
const data = dataArray[0]; const data = dataArray[0];
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber(); const number = useNumber();
if (!data) { if (!data) {
return null; return null;
} }
const revenue = data.total_revenue ?? 0;
const prevRevenue = data.prev_total_revenue ?? 0;
return ( return (
<> <>
<div className="flex justify-between gap-8 text-muted-foreground"> <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="flex gap-2">
<div <div
className="w-[3px] rounded-full" className="w-[3px] rounded-full"
style={{ background: getChartColor(0) }} style={{ background: anyMetric ? getChartColor(0) : '#3ba974' }}
/> />
<div className="col flex-1 gap-1"> <div className="col flex-1 gap-1">
<div className="flex items-center gap-1">{metric.title}</div> <div className="flex items-center gap-1">{metric.title}</div>
<div className="flex justify-between gap-8 font-mono font-medium"> <div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1"> <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}`] && ( {!!data[`prev_${metric.key}`] && (
<span className="text-muted-foreground"> <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> </span>
)} )}
</div> </div>
@@ -238,6 +226,32 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
</div> </div>
</div> </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> </React.Fragment>
</> </>
); );
@@ -247,17 +261,19 @@ function Chart({
activeMetric, activeMetric,
interval, interval,
data, data,
chartType,
projectId, projectId,
}: { }: {
activeMetric: (typeof TITLES)[number]; activeMetric: (typeof TITLES)[number];
interval: IInterval; interval: IInterval;
data: RouterOutputs['overview']['stats']['series']; data: RouterOutputs['overview']['stats']['series'];
chartType: 'bars' | 'lines';
projectId: string; projectId: string;
}) { }) {
const xAxisProps = useXAxisProps({ interval }); const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps(); const yAxisProps = useYAxisProps();
const number = useNumber();
const revenueYAxisProps = useYAxisProps({
tickFormatter: (value) => number.short(value / 100),
});
const [activeBar, setActiveBar] = useState(-1); const [activeBar, setActiveBar] = useState(-1);
const { range, startDate, endDate } = useOverviewOptions(); const { range, startDate, endDate } = useOverviewOptions();
@@ -278,13 +294,11 @@ function Chart({
// Line chart specific logic // Line chart specific logic
let dotIndex = undefined; let dotIndex = undefined;
if (chartType === 'lines') { if (interval === 'hour') {
if (interval === 'hour') { // Find closest index based on times
// Find closest index based on times dotIndex = data.findIndex((item) => {
dotIndex = data.findIndex((item) => { return isSameHour(item.date, new Date());
return isSameHour(item.date, new Date()); });
});
}
} }
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } = const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
@@ -294,6 +308,10 @@ function Chart({
const lastSerieDataItem = last(data)?.date || new Date(); const lastSerieDataItem = last(data)?.date || new Date();
const useDashedLastLine = (() => { const useDashedLastLine = (() => {
if (range === 'today') {
return true;
}
if (interval === 'hour') { if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date()); return isSameHour(lastSerieDataItem, new Date());
} }
@@ -313,11 +331,11 @@ function Chart({
return false; return false;
})(); })();
if (chartType === 'lines') { if (activeMetric.key === 'total_revenue') {
return ( return (
<TooltipProvider metric={activeMetric} interval={interval}> <TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data}> <ComposedChart data={data}>
<Customized component={calcStrokeDasharray} /> <Customized component={calcStrokeDasharray} />
<Line <Line
dataKey="calcStrokeDasharray" dataKey="calcStrokeDasharray"
@@ -326,13 +344,8 @@ function Chart({
onAnimationEnd={handleAnimationEnd} onAnimationEnd={handleAnimationEnd}
/> />
<Tooltip /> <Tooltip />
<YAxis <YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
{...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
width={25}
/>
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
horizontal={true} horizontal={true}
@@ -340,10 +353,30 @@ function Chart({
className="stroke-border" 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 <Line
key={`prev_${activeMetric.key}`} key={'prev_total_revenue'}
type="linear" type="monotone"
dataKey={`prev_${activeMetric.key}`} dataKey={'prev_total_revenue'}
stroke={'oklch(from var(--foreground) l c h / 0.1)'} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false} isAnimationActive={false}
@@ -352,24 +385,26 @@ function Chart({
? false ? false
: { : {
stroke: 'oklch(from var(--foreground) l c h / 0.1)', stroke: 'oklch(from var(--foreground) l c h / 0.1)',
fill: 'var(--def-100)', fill: 'transparent',
strokeWidth: 1.5, strokeWidth: 1.5,
r: 2, r: 2,
} }
} }
activeDot={{ activeDot={{
stroke: 'oklch(from var(--foreground) l c h / 0.2)', stroke: 'oklch(from var(--foreground) l c h / 0.2)',
fill: 'var(--def-100)', fill: 'transparent',
strokeWidth: 1.5, strokeWidth: 1.5,
r: 3, r: 3,
}} }}
/> />
<Line <Area
key={activeMetric.key} key={'total_revenue'}
type="linear" type="monotone"
dataKey={activeMetric.key} dataKey={'total_revenue'}
stroke={getChartColor(0)} stroke={'#3ba974'}
fill={'#3ba974'}
fillOpacity={0.05}
strokeWidth={2} strokeWidth={2}
strokeDasharray={ strokeDasharray={
useDashedLastLine useDashedLastLine
@@ -381,18 +416,19 @@ function Chart({
data.length > 90 data.length > 90
? false ? false
: { : {
stroke: getChartColor(0), stroke: '#3ba974',
fill: 'var(--def-100)', fill: '#3ba974',
strokeWidth: 1.5, strokeWidth: 1.5,
r: 3, r: 3,
} }
} }
activeDot={{ activeDot={{
stroke: getChartColor(0), stroke: '#3ba974',
fill: 'var(--def-100)', fill: 'var(--def-100)',
strokeWidth: 2, strokeWidth: 2,
r: 4, r: 4,
}} }}
filter="url(#rainbow-line-glow)"
/> />
{references.data?.map((ref) => ( {references.data?.map((ref) => (
@@ -410,36 +446,48 @@ function Chart({
fontSize={10} fontSize={10}
/> />
))} ))}
</LineChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</TooltipProvider> </TooltipProvider>
); );
} }
// Bar chart (default)
return ( return (
<TooltipProvider metric={activeMetric} interval={interval}> <TooltipProvider metric={activeMetric} interval={interval} anyMetric={true}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <ComposedChart
data={data} data={data}
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
onMouseMove={(e) => { onMouseMove={(e) => {
setActiveBar(e.activeTooltipIndex ?? -1); setActiveBar(e.activeTooltipIndex ?? -1);
}} }}
barCategoryGap={2}
> >
<Tooltip <Customized component={calcStrokeDasharray} />
cursor={{ <Line
stroke: 'var(--def-200)', dataKey="calcStrokeDasharray"
fill: 'var(--def-200)', legendType="none"
}} animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/> />
<Tooltip />
<YAxis <YAxis
{...yAxisProps} {...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']} domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
width={25} 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 <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
@@ -448,21 +496,103 @@ function Chart({
className="stroke-border" 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}`} key={`prev_${activeMetric.key}`}
type="monotone"
dataKey={`prev_${activeMetric.key}`} dataKey={`prev_${activeMetric.key}`}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2}
isAnimationActive={false} isAnimationActive={false}
shape={(props: any) => ( dot={
<BarShapeGrey isActive={activeBar === props.index} {...props} /> 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 <Bar
key={activeMetric.key} key="total_revenue"
dataKey={activeMetric.key} dataKey="total_revenue"
yAxisId="right"
stackId="revenue"
isAnimationActive={false} isAnimationActive={false}
shape={(props: any) => ( radius={5}
<BarShapeBlue isActive={activeBar === props.index} {...props} /> 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) => ( {references.data?.map((ref) => (
@@ -480,7 +610,7 @@ function Chart({
fontSize={10} fontSize={10}
/> />
))} ))}
</BarChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -8,6 +8,43 @@ import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip'; import { Tooltiper } from '../ui/tooltip';
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table'; 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> & { type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number; getColumnPercentage: (item: T) => number;
}; };
@@ -45,9 +82,7 @@ export const OverviewWidgetTable = <T,>({
index === 0 index === 0
? 'text-left w-full font-medium min-w-0' ? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono', : 'text-right font-mono',
index !== 0 && // Remove old responsive logic - now handled by responsive prop
index !== columns.length - 1 &&
'hidden @[310px]:table-cell',
column.className, column.className,
), ),
}; };
@@ -119,12 +154,15 @@ export function OverviewWidgetTablePages({
avg_duration: number; avg_duration: number;
bounce_rate: number; bounce_rate: number;
sessions: number; sessions: number;
revenue: number;
}[]; }[];
showDomain?: boolean; showDomain?: boolean;
}) { }) {
const [_filters, setFilter] = useEventQueryFilters(); const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber(); const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions)); 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 ( return (
<OverviewWidgetTable <OverviewWidgetTable
className={className} className={className}
@@ -135,6 +173,7 @@ export function OverviewWidgetTablePages({
{ {
name: 'Path', name: 'Path',
width: 'w-full', width: 'w-full',
responsive: { priority: 1 }, // Always visible
render(item) { render(item) {
return ( return (
<Tooltiper asChild content={item.origin + item.path} side="left"> <Tooltiper asChild content={item.origin + item.path} side="left">
@@ -178,6 +217,7 @@ export function OverviewWidgetTablePages({
{ {
name: 'BR', name: 'BR',
width: '60px', width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) { render(item) {
return number.shortWithUnit(item.bounce_rate, '%'); return number.shortWithUnit(item.bounce_rate, '%');
}, },
@@ -185,13 +225,41 @@ export function OverviewWidgetTablePages({
{ {
name: 'Duration', name: 'Duration',
width: '75px', width: '75px',
responsive: { priority: 7 }, // Hidden when space is tight
render(item) { render(item) {
return number.shortWithUnit(item.avg_duration, 'min'); 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, name: lastColumnName,
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -303,20 +371,24 @@ export function OverviewWidgetTableGeneric({
}) { }) {
const number = useNumber(); const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions)); 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 ( return (
<OverviewWidgetTable <OverviewWidgetTable
className={className} className={className}
data={data ?? []} data={data ?? []}
keyExtractor={(item) => item.name} keyExtractor={(item) => item.prefix + item.name}
getColumnPercentage={(item) => item.sessions / maxSessions} getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[ columns={[
{ {
...column, ...column,
width: 'w-full', width: 'w-full',
responsive: { priority: 1 }, // Always visible
}, },
{ {
name: 'BR', name: 'BR',
width: '60px', width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) { render(item) {
return number.shortWithUnit(item.bounce_rate, '%'); return number.shortWithUnit(item.bounce_rate, '%');
}, },
@@ -327,9 +399,38 @@ export function OverviewWidgetTableGeneric({
// return number.shortWithUnit(item.avg_session_duration, 'min'); // 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', name: 'Sessions',
width: '84px', width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">

View File

@@ -12,72 +12,91 @@ const PROFILE_METRICS = [
key: 'totalEvents', key: 'totalEvents',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Sessions', title: 'Sessions',
key: 'sessions', key: 'sessions',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Page Views', title: 'Page Views',
key: 'screenViews', key: 'screenViews',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Avg Events/Session', title: 'Avg Events/Session',
key: 'avgEventsPerSession', key: 'avgEventsPerSession',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Bounce Rate', title: 'Bounce Rate',
key: 'bounceRate', key: 'bounceRate',
unit: '%', unit: '%',
inverted: true, inverted: true,
hideOnZero: false,
}, },
{ {
title: 'Session Duration (Avg)', title: 'Session Duration (Avg)',
key: 'durationAvg', key: 'durationAvg',
unit: 'min', unit: 'min',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Session Duration (P90)', title: 'Session Duration (P90)',
key: 'durationP90', key: 'durationP90',
unit: 'min', unit: 'min',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'First seen', title: 'First seen',
key: 'firstSeen', key: 'firstSeen',
unit: 'timeAgo', unit: 'timeAgo',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Last seen', title: 'Last seen',
key: 'lastSeen', key: 'lastSeen',
unit: 'timeAgo', unit: 'timeAgo',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Days Active', title: 'Days Active',
key: 'uniqueDaysActive', key: 'uniqueDaysActive',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Conversion Events', title: 'Conversion Events',
key: 'conversionEvents', key: 'conversionEvents',
unit: '', unit: '',
inverted: false, inverted: false,
hideOnZero: false,
}, },
{ {
title: 'Avg Time Between Sessions (h)', title: 'Avg Time Between Sessions (h)',
key: 'avgTimeBetweenSessions', key: 'avgTimeBetweenSessions',
unit: 'min', unit: 'min',
inverted: false, inverted: false,
hideOnZero: false,
},
{
title: 'Revenue',
key: 'revenue',
unit: 'currency',
inverted: false,
hideOnZero: true,
}, },
] as const; ] as const;
@@ -85,7 +104,12 @@ export const ProfileMetrics = ({ data }: Props) => {
return ( return (
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0"> <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"> <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 <OverviewMetricCard
key={metric.key} key={metric.key}
id={metric.key} id={metric.key}

View File

@@ -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 { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
@@ -7,11 +7,11 @@ import type { IServiceProject } from '@openpanel/db';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'; import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { ChartSSR } from '../chart-ssr';
import { FadeIn } from '../fade-in'; import { FadeIn } from '../fade-in';
import { SerieIcon } from '../report-chart/common/serie-icon'; import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton'; import { Skeleton } from '../skeleton';
import { LinkButton } from '../ui/button'; import { LinkButton } from '../ui/button';
import { ProjectChart } from './project-chart';
export function ProjectCardRoot({ export function ProjectCardRoot({
children, children,
@@ -60,7 +60,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
</div> </div>
</div> </div>
<div className="-mx-4 aspect-[8/1] mb-4"> <div className="-mx-4 aspect-[8/1] mb-4">
<ProjectChart id={id} /> <ProjectChartOuter id={id} />
</div> </div>
<div className="flex flex-1 gap-4 h-9 md:h-4"> <div className="flex flex-1 gap-4 h-9 md:h-4">
<ProjectMetrics id={id} /> <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 trpc = useTRPC();
const { data } = useQuery( const { data } = useQuery(
trpc.chart.projectCard.queryOptions({ trpc.chart.projectCard.queryOptions({
@@ -87,7 +87,7 @@ function ProjectChart({ id }: { id: string }) {
return ( return (
<FadeIn className="h-full w-full"> <FadeIn className="h-full w-full">
<ChartSSR data={data?.chart || []} color={'blue'} /> <ProjectChart data={data?.chart || []} color={'blue'} />
</FadeIn> </FadeIn>
); );
} }
@@ -102,6 +102,7 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
} }
function ProjectMetrics({ id }: { id: string }) { function ProjectMetrics({ id }: { id: string }) {
const number = useNumber();
const trpc = useTRPC(); const trpc = useTRPC();
const { data } = useQuery( const { data } = useQuery(
trpc.chart.projectCard.queryOptions({ 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> </div>
<Metric <Metric label="3M" value={number.short(data?.metrics?.months_3 ?? 0)} />
label="3M" <Metric label="30D" value={number.short(data?.metrics?.month ?? 0)} />
value={shortNumber('en')(data?.metrics?.months_3 ?? 0)} <Metric label="24H" value={number.short(data?.metrics?.day ?? 0)} />
/>
<Metric
label="30D"
value={shortNumber('en')(data?.metrics?.month ?? 0)}
/>
<Metric label="24H" value={shortNumber('en')(data?.metrics?.day ?? 0)} />
</FadeIn> </FadeIn>
); );
} }

View 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>
);
}

View File

@@ -1,126 +1,99 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query'; 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 { AnimatedNumber } from '../animated-number';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface RealtimeLiveHistogramProps { interface RealtimeLiveHistogramProps {
projectId: string; 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({ export function RealtimeLiveHistogram({
projectId, projectId,
}: RealtimeLiveHistogramProps) { }: RealtimeLiveHistogramProps) {
const report = getReport(projectId);
const countReport = getCountReport(projectId);
const trpc = useTRPC(); 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; // Use the same liveData endpoint as overview
const minutes = (res.data?.series[0]?.data || []).slice(-30); const { data: liveData, isLoading } = useQuery(
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0; trpc.overview.liveData.queryOptions({ projectId }),
);
if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) { const chartData = liveData?.minuteCounts ?? [];
const staticArray = [ // Calculate total unique visitors (sum of unique visitors per minute)
10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52, // Note: This is an approximation - ideally we'd want unique visitors across all minutes
5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5, const totalVisitors = liveData?.totalSessions ?? 0;
];
if (isLoading) {
return ( return (
<Wrapper count={0}> <Wrapper count={0}>
{staticArray.map((percent, i) => ( <div className="h-full w-full animate-pulse bg-def-200 rounded" />
<div
key={i as number}
className="flex-1 animate-pulse rounded-sm bg-def-200"
style={{ height: `${percent}%` }}
/>
))}
</Wrapper> </Wrapper>
); );
} }
if (!res.isSuccess && !countRes.isSuccess) { if (!liveData) {
return null; return null;
} }
const maxDomain =
Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1;
return ( return (
<Wrapper count={liveCount}> <Wrapper
{minutes.map((minute) => { count={totalVisitors}
return ( icons={
<Tooltip key={minute.date}> liveData.referrers && liveData.referrers.length > 0 ? (
<TooltipTrigger asChild> <div className="row gap-2">
{liveData.referrers.slice(0, 3).map((ref, index) => (
<div <div
className={cn( key={`${ref.referrer}-${ref.count}-${index}`}
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110', className="font-bold text-xs row gap-1 items-center"
minute.count === 0 ? 'bg-def-200' : 'bg-highlight', >
)} <SerieIcon name={ref.referrer} />
style={{ <span>{ref.count}</span>
height: </div>
minute.count === 0 ))}
? '20%' </div>
: `${(minute.count / metrics!.max) * 100}%`, ) : null
}} }
/> >
</TooltipTrigger> <ResponsiveContainer width="100%" height="100%">
<TooltipContent side="top"> <BarChart
<div>{minute.count} active users</div> data={chartData}
<div>@ {new Date(minute.date).toLocaleTimeString()}</div> margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
</TooltipContent> >
</Tooltip> <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> </Wrapper>
); );
} }
@@ -128,22 +101,144 @@ export function RealtimeLiveHistogram({
interface WrapperProps { interface WrapperProps {
children: React.ReactNode; children: React.ReactNode;
count: number; count: number;
icons?: React.ReactNode;
} }
function Wrapper({ children, count }: WrapperProps) { function Wrapper({ children, count, icons }: WrapperProps) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="col gap-2 p-4"> <div className="row gap-2 justify-between mb-2">
<div className="font-medium text-muted-foreground"> <div className="relative text-sm font-medium text-muted-foreground leading-normal">
Unique vistors last 30 minutes Unique visitors {icons ? <br /> : null}
last 30 min
</div> </div>
<div>{icons}</div>
</div>
<div className="col gap-2 mb-4">
<div className="font-mono text-6xl font-bold"> <div className="font-mono text-6xl font-bold">
<AnimatedNumber value={count} /> <AnimatedNumber value={count} />
</div> </div>
</div> </div>
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5"> <div className="relative aspect-[6/1] w-full">{children}</div>
{children}
</div>
</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>
);
};

View File

@@ -1,7 +1,6 @@
import useWS from '@/hooks/use-ws'; import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { getCountReport, getReport } from './realtime-live-histogram';
type Props = { type Props = {
projectId: string; projectId: string;
@@ -17,11 +16,15 @@ const RealtimeReloader = ({ projectId }: Props) => {
if (!document.hidden) { if (!document.hidden) {
client.refetchQueries(trpc.realtime.pathFilter()); client.refetchQueries(trpc.realtime.pathFilter());
client.refetchQueries( client.refetchQueries(
trpc.chart.chart.queryFilter(getReport(projectId)), trpc.overview.liveData.queryFilter({ projectId }),
); );
client.refetchQueries( 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 }));
} }
}, },
{ {

View File

@@ -6,7 +6,6 @@ import { useRef, useState } from 'react';
import type { AxisDomain } from 'recharts/types/util/types'; import type { AxisDomain } from 'recharts/types/util/types';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
export const AXIS_FONT_PROPS = { export const AXIS_FONT_PROPS = {
fontSize: 8, fontSize: 8,
className: 'font-mono', className: 'font-mono',
@@ -69,9 +68,11 @@ export const useXAxisProps = (
interval: 'auto', interval: 'auto',
}, },
) => { ) => {
const formatDate = useFormatDateInterval( const formatDate = useFormatDateInterval({
interval === 'auto' ? 'day' : interval, interval: interval === 'auto' ? 'day' : interval,
); short: true,
});
return { return {
...X_AXIS_STYLE_PROPS, ...X_AXIS_STYLE_PROPS,
height: hide ? 0 : X_AXIS_STYLE_PROPS.height, height: hide ? 0 : X_AXIS_STYLE_PROPS.height,

View File

@@ -62,7 +62,10 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
const { const {
report: { interval, unit }, report: { interval, unit },
} = useReportChartContext(); } = useReportChartContext();
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber(); const number = useNumber();
if (!data || data.length === 0) { if (!data || data.length === 0) {

View File

@@ -40,7 +40,10 @@ export function ReportTable({
const number = useNumber(); const number = useNumber();
const interval = useSelector((state) => state.report.interval); const interval = useSelector((state) => state.report.interval);
const breakdowns = useSelector((state) => state.report.breakdowns); const breakdowns = useSelector((state) => state.report.breakdowns);
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval({
interval,
short: true,
});
function handleChange(name: string, checked: boolean) { function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => { setVisibleSeries((prev) => {

View File

@@ -1,6 +1,8 @@
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useCallback } from 'react';
import { import {
CartesianGrid, CartesianGrid,
Line, Line,
@@ -10,8 +12,6 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { pushModal } from '@/modals';
import { useCallback } from 'react';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
@@ -171,7 +171,10 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
} }
const { date } = data[0]; const { date } = data[0];
const formatDate = useFormatDateInterval(context.interval); const formatDate = useFormatDateInterval({
interval: context.interval,
short: false,
});
const number = useNumber(); const number = useNumber();
return ( return (
<> <>

View File

@@ -26,6 +26,7 @@ const validator = zProject.pick({
domain: true, domain: true,
cors: true, cors: true,
crossDomain: true, crossDomain: true,
allowUnsafeRevenueTracking: true,
}); });
type IForm = z.infer<typeof validator>; type IForm = z.infer<typeof validator>;
@@ -39,6 +40,7 @@ export default function EditProjectDetails({ project }: Props) {
domain: project.domain, domain: project.domain,
cors: project.cors, cors: project.cors,
crossDomain: project.crossDomain, crossDomain: project.crossDomain,
allowUnsafeRevenueTracking: project.allowUnsafeRevenueTracking,
}, },
}); });
const trpc = useTRPC(); const trpc = useTRPC();
@@ -155,22 +157,45 @@ export default function EditProjectDetails({ project }: Props) {
control={form.control} control={form.control}
render={({ field }) => { render={({ field }) => {
return ( 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 <CheckboxInput
className="mt-4"
ref={field.ref} ref={field.ref}
onBlur={field.onBlur} onBlur={field.onBlur}
defaultChecked={field.value} defaultChecked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
> >
<div>Enable cross domain support</div> <div>Allow "unsafe" revenue tracking</div>
<div className="font-normal text-muted-foreground"> <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> </div>
</CheckboxInput> </CheckboxInput>
); </WithLabel>
}} );
/> }}
</AnimateHeight> />
<Button <Button
loading={mutation.isPending} loading={mutation.isPending}

View File

@@ -1,4 +1,22 @@
import { cn } from '@/utils/cn'; 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> { export interface Props<T> {
columns: { columns: {
@@ -6,6 +24,11 @@ export interface Props<T> {
render: (item: T, index: number) => React.ReactNode; render: (item: T, index: number) => React.ReactNode;
className?: string; className?: string;
width: string; width: string;
/**
* Responsive settings for this column.
* If not provided, column is always visible.
*/
responsive?: ColumnResponsive;
}[]; }[];
keyExtractor: (item: T) => string; keyExtractor: (item: T) => string;
data: T[]; 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>({ export function WidgetTable<T>({
className, className,
columns, columns,
@@ -49,29 +110,91 @@ export function WidgetTable<T>({
.join(' ')}` .join(' ')}`
: '1fr'; : '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 ( return (
<div className="w-full overflow-x-auto"> <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 */} {/* Header */}
<div <div
className={cn('grid border-b border-border head', columnClassName)} className={cn('grid border-b border-border head', columnClassName)}
style={{ gridTemplateColumns }} style={{ gridTemplateColumns }}
> >
{columns.map((column) => ( {columns.map((column, colIndex) => {
<div const responsiveClass =
key={column.name?.toString()} column.responsive?.priority !== undefined
className={cn( ? getResponsiveClass(column.responsive.priority)
'p-2 font-medium font-sans text-sm whitespace-nowrap cell', : column.responsive?.minWidth !== undefined
columns.length > 1 && column !== columns[0] ? getMinWidthClass(column.responsive.minWidth)
? 'text-right' : '';
: 'text-left',
column.className, const dataAttrs: Record<string, string> = {};
)} if (column.responsive?.priority !== undefined) {
style={{ width: column.width }} dataAttrs['data-priority'] = String(column.responsive.priority);
> }
{column.name} if (column.responsive?.minWidth !== undefined) {
</div> 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> </div>
{/* Body */} {/* Body */}
@@ -89,24 +212,47 @@ export function WidgetTable<T>({
className="grid h-8 items-center" className="grid h-8 items-center"
style={{ gridTemplateColumns }} style={{ gridTemplateColumns }}
> >
{columns.map((column) => ( {columns.map((column, colIndex) => {
<div const responsiveClass =
key={column.name?.toString()} column.responsive?.priority !== undefined
className={cn( ? getResponsiveClass(column.responsive.priority)
'px-2 relative cell', : column.responsive?.minWidth !== undefined
columns.length > 1 && column !== columns[0] ? getMinWidthClass(column.responsive.minWidth)
? 'text-right' : '';
: 'text-left',
column.className, const dataAttrs: Record<string, string> = {};
column.width === 'w-full' && 'w-full min-w-0', if (column.responsive?.priority !== undefined) {
)} dataAttrs['data-priority'] = String(
style={ column.responsive.priority,
column.width !== 'w-full' ? { width: column.width } : {} );
} }
> if (column.responsive?.minWidth !== undefined) {
{column.render(item, index)} dataAttrs['data-min-width'] = String(
</div> 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>
</div> </div>
))} ))}

View File

@@ -1,9 +1,20 @@
import type { IInterval } from '@openpanel/validation'; 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 { try {
if (interval === 'hour' || interval === 'minute') { if (interval === 'hour' || interval === 'minute') {
return new Intl.DateTimeFormat('en-GB', { return new Intl.DateTimeFormat('en-GB', {
...(!short
? {
month: '2-digit',
day: '2-digit',
}
: {}),
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}).format(date); }).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) => return (date: Date | string) =>
formatDateInterval( formatDateInterval({
interval, ...options,
typeof date === 'string' ? new Date(date) : date, date: typeof date === 'string' ? new Date(date) : date,
); });
} }

View File

@@ -33,7 +33,35 @@ export const shortNumber =
export const formatCurrency = export const formatCurrency =
(locale: string) => (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, { return new Intl.NumberFormat(locale, {
style: 'currency', style: 'currency',
currency: currency, currency: currency,

View File

@@ -5,6 +5,7 @@ import {
useEventQueryNamesFilter, useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters'; } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { parseAsIsoDateTime, useQueryState } from 'nuqs';
@@ -13,6 +14,15 @@ export const Route = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events', '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events',
)({ )({
component: Component, component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.PROFILE_EVENTS),
},
],
};
},
}); });
function Component() { function Component() {

View File

@@ -7,6 +7,7 @@ import { ProfileCharts } from '@/components/profiles/profile-charts';
import { ProfileMetrics } from '@/components/profiles/profile-metrics'; import { ProfileMetrics } from '@/components/profiles/profile-metrics';
import { ProfileProperties } from '@/components/profiles/profile-properties'; import { ProfileProperties } from '@/components/profiles/profile-properties';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
@@ -44,6 +45,15 @@ export const Route = createFileRoute(
]); ]);
}, },
pendingComponent: FullPageLoadingState, pendingComponent: FullPageLoadingState,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.PROFILE_DETAILS),
},
],
};
},
}); });
function Component() { function Component() {

View File

@@ -80,7 +80,6 @@ export const PAGE_TITLES = {
DASHBOARD: 'Dashboard', DASHBOARD: 'Dashboard',
EVENTS: 'Events', EVENTS: 'Events',
SESSIONS: 'Sessions', SESSIONS: 'Sessions',
PROFILES: 'Profiles',
PAGES: 'Pages', PAGES: 'Pages',
REPORTS: 'Reports', REPORTS: 'Reports',
NOTIFICATIONS: 'Notifications', NOTIFICATIONS: 'Notifications',
@@ -91,6 +90,10 @@ export const PAGE_TITLES = {
CHAT: 'AI Assistant', CHAT: 'AI Assistant',
REALTIME: 'Realtime', REALTIME: 'Realtime',
REFERENCES: 'References', REFERENCES: 'References',
// Profiles
PROFILES: 'Profiles',
PROFILE_EVENTS: 'Profile events',
PROFILE_DETAILS: 'Profile details',
// Sub-sections // Sub-sections
CONVERSIONS: 'Conversions', CONVERSIONS: 'Conversions',

View File

@@ -15,6 +15,7 @@ import {
getHasFunnelRules, getHasFunnelRules,
getNotificationRulesByProjectId, getNotificationRulesByProjectId,
sessionBuffer, sessionBuffer,
transformSessionToEvent,
} from '@openpanel/db'; } from '@openpanel/db';
import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue'; import type { EventsQueuePayloadCreateSessionEnd } from '@openpanel/queue';
@@ -31,7 +32,7 @@ async function getSessionEvents({
sessionId: string; sessionId: string;
startAt: Date; startAt: Date;
endAt: Date; endAt: Date;
}): Promise<ReturnType<typeof getEvents>> { }): Promise<IServiceEvent[]> {
const sql = ` const sql = `
SELECT * FROM ${TABLE_NAMES.events} SELECT * FROM ${TABLE_NAMES.events}
WHERE WHERE
@@ -42,16 +43,18 @@ async function getSessionEvents({
`; `;
const [lastScreenView, eventsInDb] = await Promise.all([ const [lastScreenView, eventsInDb] = await Promise.all([
eventBuffer.getLastScreenView({ sessionBuffer.getExistingSession({
projectId,
sessionId, sessionId,
}), }),
getEvents(sql), getEvents(sql),
]); ]);
// sort last inserted first // sort last inserted first
return [lastScreenView, ...eventsInDb] return [
.filter((event): event is IServiceEvent => !!event) lastScreenView ? transformSessionToEvent(lastScreenView) : null,
...eventsInDb,
]
.flatMap((event) => (event ? [event] : []))
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
@@ -69,7 +72,9 @@ export async function createSessionEnd(
logger.debug('Processing session end job'); logger.debug('Processing session end job');
const session = await sessionBuffer.getExistingSession(payload.sessionId); const session = await sessionBuffer.getExistingSession({
sessionId: payload.sessionId,
});
if (!session) { if (!session) {
throw new Error('Session not found'); 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 // Create session end event
return createEvent({ return createEvent({
...payload, ...payload,
properties: { properties: {
...payload.properties, ...payload.properties,
...(lastScreenView?.properties ?? {}), ...(session?.properties ?? {}),
__bounce: session.is_bounce, __bounce: session.is_bounce,
}, },
name: 'session_end', name: 'session_end',
duration: session.duration ?? 0, duration: session.duration ?? 0,
path: lastScreenView?.path ?? '', path: session.exit_path ?? '',
createdAt: new Date( 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,
}); });
} }

View File

@@ -14,14 +14,14 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import { import {
checkNotificationRulesForEvent, checkNotificationRulesForEvent,
createEvent, createEvent,
eventBuffer, sessionBuffer,
} from '@openpanel/db'; } from '@openpanel/db';
import type { ILogger } from '@openpanel/logger'; import type { ILogger } from '@openpanel/logger';
import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue'; import type { EventsQueuePayloadIncomingEvent } from '@openpanel/queue';
import * as R from 'ramda'; import * as R from 'ramda';
import { v4 as uuid } from 'uuid'; 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. // This function will merge two objects.
// First it will strip '' and undefined/null from B // First it will strip '' and undefined/null from B
@@ -41,6 +41,17 @@ async function createEventAndNotify(
return event; 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( export async function incomingEvent(
jobPayload: EventsQueuePayloadIncomingEvent['payload'], jobPayload: EventsQueuePayloadIncomingEvent['payload'],
) { ) {
@@ -115,12 +126,16 @@ export async function incomingEvent(
device: uaInfo.device, device: uaInfo.device,
brand: uaInfo.brand, brand: uaInfo.brand,
model: uaInfo.model, model: uaInfo.model,
revenue:
body.name === 'revenue' && '__revenue' in properties
? parseRevenue(properties.__revenue)
: undefined,
} as const; } as const;
// if timestamp is from the past we dont want to create a new session // if timestamp is from the past we dont want to create a new session
if (uaInfo.isServer || isTimestampFromThePast) { if (uaInfo.isServer || isTimestampFromThePast) {
const screenView = profileId const session = profileId
? await eventBuffer.getLastScreenView({ ? await sessionBuffer.getExistingSession({
profileId, profileId,
projectId, projectId,
}) })
@@ -128,25 +143,25 @@ export async function incomingEvent(
const payload = { const payload = {
...baseEvent, ...baseEvent,
deviceId: screenView?.deviceId ?? '', deviceId: session?.device_id ?? '',
sessionId: screenView?.sessionId ?? '', sessionId: session?.id ?? '',
referrer: screenView?.referrer ?? undefined, referrer: session?.referrer ?? undefined,
referrerName: screenView?.referrerName ?? undefined, referrerName: session?.referrer_name ?? undefined,
referrerType: screenView?.referrerType ?? undefined, referrerType: session?.referrer_type ?? undefined,
path: screenView?.path ?? baseEvent.path, path: session?.exit_path ?? baseEvent.path,
os: screenView?.os ?? baseEvent.os, origin: session?.exit_origin ?? baseEvent.origin,
osVersion: screenView?.osVersion ?? baseEvent.osVersion, os: session?.os ?? baseEvent.os,
browserVersion: screenView?.browserVersion ?? baseEvent.browserVersion, osVersion: session?.os_version ?? baseEvent.osVersion,
browser: screenView?.browser ?? baseEvent.browser, browserVersion: session?.browser_version ?? baseEvent.browserVersion,
device: screenView?.device ?? baseEvent.device, browser: session?.browser ?? baseEvent.browser,
brand: screenView?.brand ?? baseEvent.brand, device: session?.device ?? baseEvent.device,
model: screenView?.model ?? baseEvent.model, brand: session?.brand ?? baseEvent.brand,
city: screenView?.city ?? baseEvent.city, model: session?.model ?? baseEvent.model,
country: screenView?.country ?? baseEvent.country, city: session?.city ?? baseEvent.city,
region: screenView?.region ?? baseEvent.region, country: session?.country ?? baseEvent.country,
longitude: screenView?.longitude ?? baseEvent.longitude, region: session?.region ?? baseEvent.region,
latitude: screenView?.latitude ?? baseEvent.latitude, longitude: session?.longitude ?? baseEvent.longitude,
origin: screenView?.origin ?? baseEvent.origin, latitude: session?.latitude ?? baseEvent.latitude,
}; };
return createEventAndNotify(payload as IServiceEvent, logger); return createEventAndNotify(payload as IServiceEvent, logger);
@@ -160,8 +175,7 @@ export async function incomingEvent(
}); });
const lastScreenView = sessionEnd const lastScreenView = sessionEnd
? await eventBuffer.getLastScreenView({ ? await sessionBuffer.getExistingSession({
projectId,
sessionId: sessionEnd.sessionId, sessionId: sessionEnd.sessionId,
}) })
: null; : null;
@@ -173,8 +187,8 @@ export async function incomingEvent(
referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName, referrerName: sessionEnd?.referrerName ?? baseEvent.referrerName,
referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType, referrerType: sessionEnd?.referrerType ?? baseEvent.referrerType,
// if the path is not set, use the last screen view path // if the path is not set, use the last screen view path
path: baseEvent.path || lastScreenView?.path || '', path: baseEvent.path || lastScreenView?.exit_path || '',
origin: baseEvent.origin || lastScreenView?.origin || '', origin: baseEvent.origin || lastScreenView?.exit_origin || '',
} as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload; } as Partial<IServiceCreateEventPayload>) as IServiceCreateEventPayload;
if (!sessionEnd) { if (!sessionEnd) {

View File

@@ -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 { eventBuffer } from '@openpanel/db';
import { import {
type EventsQueuePayloadIncomingEvent, type EventsQueuePayloadIncomingEvent,
@@ -14,10 +21,9 @@ vi.mock('@openpanel/db', async () => {
return { return {
...actual, ...actual,
createEvent: vi.fn(), createEvent: vi.fn(),
getLastScreenView: vi.fn(),
checkNotificationRulesForEvent: vi.fn().mockResolvedValue(true), checkNotificationRulesForEvent: vi.fn().mockResolvedValue(true),
eventBuffer: { sessionBuffer: {
getLastScreenView: vi.fn(), getExistingSession: vi.fn(),
}, },
}; };
}); });
@@ -106,6 +112,7 @@ describe('incomingEvent', () => {
country: 'US', country: 'US',
city: 'New York', city: 'New York',
region: 'NY', region: 'NY',
revenue: undefined,
longitude: 0, longitude: 0,
latitude: 0, latitude: 0,
os: 'Windows', os: 'Windows',
@@ -210,6 +217,7 @@ describe('incomingEvent', () => {
country: 'US', country: 'US',
city: 'New York', city: 'New York',
region: 'NY', region: 'NY',
revenue: undefined,
longitude: 0, longitude: 0,
latitude: 0, latitude: 0,
os: 'Windows', os: 'Windows',
@@ -278,10 +286,47 @@ describe('incomingEvent', () => {
referrerType: 'search', referrerType: 'search',
}; };
// Mock the eventBuffer.getLastScreenView method vi.mocked(sessionBuffer.getExistingSession).mockResolvedValueOnce({
vi.mocked(eventBuffer.getLastScreenView).mockResolvedValueOnce( id: 'last-session-456',
mockLastScreenView as IServiceEvent, 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); await incomingEvent(jobData);
@@ -317,6 +362,7 @@ describe('incomingEvent', () => {
referrerType: 'search', referrerType: 'search',
sdkName: 'server', sdkName: 'server',
sdkVersion: '1.0.0', sdkVersion: '1.0.0',
revenue: undefined,
}); });
expect(sessionsQueue.add).not.toHaveBeenCalled(); expect(sessionsQueue.add).not.toHaveBeenCalled();
@@ -345,9 +391,6 @@ describe('incomingEvent', () => {
uaInfo: uaInfoServer, uaInfo: uaInfoServer,
}; };
// Mock getLastScreenView to return null
vi.mocked(eventBuffer.getLastScreenView).mockResolvedValueOnce(null);
await incomingEvent(jobData); await incomingEvent(jobData);
expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({ expect((createEvent as Mock).mock.calls[0]![0]).toStrictEqual({
@@ -365,6 +408,7 @@ describe('incomingEvent', () => {
country: 'US', country: 'US',
city: 'New York', city: 'New York',
region: 'NY', region: 'NY',
revenue: undefined,
longitude: 0, longitude: 0,
latitude: 0, latitude: 0,
os: '', os: '',

View File

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

View File

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

View 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);
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."projects" ADD COLUMN "allowUnsafeRevenueTracking" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -172,17 +172,18 @@ model Invite {
} }
model Project { model Project {
id String @id @default(dbgenerated("gen_random_uuid()")) id String @id @default(dbgenerated("gen_random_uuid()"))
name String name String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String organizationId String
eventsCount Int @default(0) eventsCount Int @default(0)
types ProjectType[] @default([]) types ProjectType[] @default([])
domain String? domain String?
cors String[] @default([]) cors String[] @default([])
crossDomain Boolean @default(false) crossDomain Boolean @default(false)
allowUnsafeRevenueTracking Boolean @default(false)
/// [IPrismaProjectFilters] /// [IPrismaProjectFilters]
filters Json @default("[]") filters Json @default("[]")
clients Client[] clients Client[]
reports Report[] reports Report[]

View File

@@ -28,8 +28,24 @@ export class SessionBuffer extends BaseBuffer {
this.redis = getRedisCache(); this.redis = getRedisCache();
} }
public async getExistingSession(sessionId: string) { public async getExistingSession(
const hit = await this.redis.get(`session:${sessionId}`); 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) { if (hit) {
return getSafeJson<IClickhouseSession>(hit); return getSafeJson<IClickhouseSession>(hit);
@@ -41,7 +57,9 @@ export class SessionBuffer extends BaseBuffer {
async getSession( async getSession(
event: IClickhouseEvent, event: IClickhouseEvent,
): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> { ): Promise<[IClickhouseSession] | [IClickhouseSession, IClickhouseSession]> {
const existingSession = await this.getExistingSession(event.session_id); const existingSession = await this.getExistingSession({
sessionId: event.session_id,
});
if (existingSession) { if (existingSession) {
const oldSession = assocPath(['sign'], -1, clone(existingSession)); const oldSession = assocPath(['sign'], -1, clone(existingSession));
@@ -77,7 +95,9 @@ export class SessionBuffer extends BaseBuffer {
...(event.properties || {}), ...(event.properties || {}),
...(newSession.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) { if (event.name === 'screen_view' && event.path) {
newSession.screen_views.push(event.path); newSession.screen_views.push(event.path);
@@ -114,7 +134,7 @@ export class SessionBuffer extends BaseBuffer {
entry_origin: event.origin, entry_origin: event.origin,
exit_path: event.path, exit_path: event.path,
exit_origin: event.origin, exit_origin: event.origin,
revenue: 0, revenue: event.name === 'revenue' ? (event.revenue ?? 0) : 0,
referrer: event.referrer, referrer: event.referrer,
referrer_name: event.referrer_name, referrer_name: event.referrer_name,
referrer_type: event.referrer_type, referrer_type: event.referrer_type,
@@ -174,6 +194,14 @@ export class SessionBuffer extends BaseBuffer {
'EX', 'EX',
60 * 60, 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) { for (const session of sessions) {
multi.rpush(this.redisKey, JSON.stringify(session)); multi.rpush(this.redisKey, JSON.stringify(session));
} }

View File

@@ -140,10 +140,10 @@ export function addColumns(
isClustered: boolean, isClustered: boolean,
): string[] { ): string[] {
if (isClustered) { if (isClustered) {
return columns.map( return columns.flatMap((col) => [
(col) => `ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS ${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( return columns.map(
@@ -160,10 +160,10 @@ export function dropColumns(
isClustered: boolean, isClustered: boolean,
): string[] { ): string[] {
if (isClustered) { if (isClustered) {
return columnNames.map( return columnNames.flatMap((colName) => [
(colName) => `ALTER TABLE ${replicated(tableName)} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS ${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( return columnNames.map(

View File

@@ -174,23 +174,43 @@ export function getChartSql({
} }
if (event.segment === 'property_sum' && event.property) { if (event.segment === 'property_sum' && event.property) {
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`; if (event.property === 'revenue') {
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; 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) { if (event.segment === 'property_average' && event.property) {
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`; if (event.property === 'revenue') {
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; 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) { if (event.segment === 'property_max' && event.property) {
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`; if (event.property === 'revenue') {
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; 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) { if (event.segment === 'property_min' && event.property) {
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`; if (event.property === 'revenue') {
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; 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') { if (event.segment === 'one_event_per_user') {

View File

@@ -25,6 +25,7 @@ import {
getProfilesCached, getProfilesCached,
upsertProfile, upsertProfile,
} from './profile.service'; } from './profile.service';
import type { IClickhouseSession } from './session.service';
export type IImportedEvent = Omit< export type IImportedEvent = Omit<
IClickhouseEvent, IClickhouseEvent,
@@ -92,12 +93,62 @@ export interface IClickhouseEvent {
imported_at: string | null; imported_at: string | null;
sdk_name: string; sdk_name: string;
sdk_version: string; sdk_version: string;
revenue?: number;
// They do not exist here. Just make ts happy for now // They do not exist here. Just make ts happy for now
profile?: IServiceProfile; profile?: IServiceProfile;
meta?: EventMeta; 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 { export function transformEvent(event: IClickhouseEvent): IServiceEvent {
return { return {
id: event.id, id: event.id,
@@ -131,6 +182,7 @@ export function transformEvent(event: IClickhouseEvent): IServiceEvent {
sdkName: event.sdk_name, sdkName: event.sdk_name,
sdkVersion: event.sdk_version, sdkVersion: event.sdk_version,
profile: event.profile, profile: event.profile,
revenue: event.revenue,
}; };
} }
@@ -178,6 +230,7 @@ export interface IServiceEvent {
meta: EventMeta | undefined; meta: EventMeta | undefined;
sdkName: string | undefined; sdkName: string | undefined;
sdkVersion: string | undefined; sdkVersion: string | undefined;
revenue?: number;
} }
type SelectHelper<T> = { type SelectHelper<T> = {
@@ -336,6 +389,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
imported_at: null, imported_at: null,
sdk_name: payload.sdkName ?? '', sdk_name: payload.sdkName ?? '',
sdk_version: payload.sdkVersion ?? '', sdk_version: payload.sdkVersion ?? '',
revenue: payload.revenue,
}; };
const promises = [sessionBuffer.add(event), eventBuffer.add(event)]; const promises = [sessionBuffer.add(event), eventBuffer.add(event)];

View File

@@ -104,6 +104,7 @@ export class OverviewService {
avg_session_duration: number; avg_session_duration: number;
total_screen_views: number; total_screen_views: number;
views_per_session: number; views_per_session: number;
total_revenue: number;
}; };
series: { series: {
date: string; date: string;
@@ -113,6 +114,7 @@ export class OverviewService {
avg_session_duration: number; avg_session_duration: number;
total_screen_views: number; total_screen_views: number;
views_per_session: number; views_per_session: number;
total_revenue: number;
}[]; }[];
}> { }> {
const where = this.getRawWhereClause('sessions', filters); const where = this.getRawWhereClause('sessions', filters);
@@ -122,6 +124,7 @@ export class OverviewService {
.select([ .select([
`${clix.toStartOf('created_at', interval, timezone)} AS date`, `${clix.toStartOf('created_at', interval, timezone)} AS date`,
'round((countIf(is_bounce = 1 AND sign = 1) * 100.) / countIf(sign = 1), 2) AS bounce_rate', '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) .from(TABLE_NAMES.sessions, true)
.where('sign', '=', 1) .where('sign', '=', 1)
@@ -165,10 +168,17 @@ export class OverviewService {
.from('session_agg') .from('session_agg')
.where('date', '=', rollupDate), .where('date', '=', rollupDate),
) )
.with(
'overall_total_revenue',
clix(this.client, timezone)
.select(['total_revenue'])
.from('session_agg')
.where('date', '=', rollupDate),
)
.with( .with(
'daily_stats', 'daily_stats',
clix(this.client, timezone) clix(this.client, timezone)
.select(['date', 'bounce_rate']) .select(['date', 'bounce_rate', 'total_revenue'])
.from('session_agg') .from('session_agg')
.where('date', '!=', rollupDate), .where('date', '!=', rollupDate),
) )
@@ -181,9 +191,11 @@ export class OverviewService {
avg_session_duration: number; avg_session_duration: number;
total_screen_views: number; total_screen_views: number;
views_per_session: number; views_per_session: number;
total_revenue: number;
overall_unique_visitors: number; overall_unique_visitors: number;
overall_total_sessions: number; overall_total_sessions: number;
overall_bounce_rate: number; overall_bounce_rate: number;
overall_total_revenue: number;
}>([ }>([
`${clix.toStartOf('e.created_at', interval)} AS date`, `${clix.toStartOf('e.created_at', interval)} AS date`,
'ds.bounce_rate as bounce_rate', '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', 'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
'count(*) AS total_screen_views', 'count(*) AS total_screen_views',
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session', '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 unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
'(SELECT total_sessions FROM overall_unique_visitors) AS overall_total_sessions', '(SELECT total_sessions FROM overall_unique_visitors) AS overall_total_sessions',
'(SELECT bounce_rate FROM overall_bounce_rate) AS overall_bounce_rate', '(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`) .from(`${TABLE_NAMES.events} AS e`)
.leftJoin( .leftJoin(
@@ -209,7 +223,7 @@ export class OverviewService {
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.rawWhere(this.getRawWhereClause('events', filters)) .rawWhere(this.getRawWhereClause('events', filters))
.groupBy(['date', 'ds.bounce_rate']) .groupBy(['date', 'ds.bounce_rate', 'ds.total_revenue'])
.orderBy('date', 'ASC') .orderBy('date', 'ASC')
.fill( .fill(
clix.toStartOf( clix.toStartOf(
@@ -234,7 +248,8 @@ export class OverviewService {
(item) => (item) =>
item.overall_bounce_rate !== null || item.overall_bounce_rate !== null ||
item.overall_total_sessions !== null || item.overall_total_sessions !== null ||
item.overall_unique_visitors !== null, item.overall_unique_visitors !== null ||
item.overall_total_revenue !== null,
); );
return { return {
metrics: { metrics: {
@@ -250,12 +265,14 @@ export class OverviewService {
views_per_session: average( views_per_session: average(
res.map((item) => item.views_per_session), res.map((item) => item.views_per_session),
), ),
total_revenue: anyRowWithData?.overall_total_revenue ?? 0,
}, },
series: res.map( series: res.map(
omit([ omit([
'overall_bounce_rate', 'overall_bounce_rate',
'overall_unique_visitors', 'overall_unique_visitors',
'overall_total_sessions', 'overall_total_sessions',
'overall_total_revenue',
]), ]),
), ),
}; };
@@ -271,6 +288,7 @@ export class OverviewService {
avg_session_duration: number; avg_session_duration: number;
total_screen_views: number; total_screen_views: number;
views_per_session: number; views_per_session: number;
total_revenue: number;
}>([ }>([
`${clix.toStartOf('created_at', interval, timezone)} AS date`, `${clix.toStartOf('created_at', interval, timezone)} AS date`,
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate', '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', 'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
'sum(sign * screen_view_count) AS total_screen_views', 'sum(sign * screen_view_count) AS total_screen_views',
'round(sum(sign * screen_view_count) * 1.0 / sum(sign), 2) AS views_per_session', 'round(sum(sign * screen_view_count) * 1.0 / sum(sign), 2) AS views_per_session',
'sum(revenue * sign) AS total_revenue',
]) ])
.from('sessions') .from('sessions')
.where('created_at', 'BETWEEN', [ .where('created_at', 'BETWEEN', [
@@ -320,6 +339,7 @@ export class OverviewService {
avg_session_duration: res[0]?.avg_session_duration ?? 0, avg_session_duration: res[0]?.avg_session_duration ?? 0,
total_screen_views: res[0]?.total_screen_views ?? 0, total_screen_views: res[0]?.total_screen_views ?? 0,
views_per_session: res[0]?.views_per_session ?? 0, views_per_session: res[0]?.views_per_session ?? 0,
total_revenue: res[0]?.total_revenue ?? 0,
}, },
series: res series: res
.slice(1) .slice(1)
@@ -394,6 +414,7 @@ export class OverviewService {
'entry_path', 'entry_path',
'entry_origin', 'entry_origin',
'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate', '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) .from(TABLE_NAMES.sessions, true)
.where('sign', '=', 1) .where('sign', '=', 1)
@@ -417,6 +438,7 @@ export class OverviewService {
avg_duration: number; avg_duration: number;
bounce_rate: number; bounce_rate: number;
sessions: number; sessions: number;
revenue: number;
}>([ }>([
'p.title', 'p.title',
'p.origin', 'p.origin',
@@ -424,6 +446,7 @@ export class OverviewService {
'p.avg_duration', 'p.avg_duration',
'p.count as sessions', 'p.count as sessions',
'b.bounce_rate', 'b.bounce_rate',
'coalesce(b.revenue, 0) as revenue',
]) ])
.from('page_stats p', false) .from('page_stats p', false)
.leftJoin( .leftJoin(
@@ -465,12 +488,14 @@ export class OverviewService {
avg_duration: number; avg_duration: number;
bounce_rate: number; bounce_rate: number;
sessions: number; sessions: number;
revenue: number;
}>([ }>([
`${mode}_origin AS origin`, `${mode}_origin AS origin`,
`${mode}_path AS path`, `${mode}_path AS path`,
'round(avg(duration * sign)/1000, 2) as avg_duration', 'round(avg(duration * sign)/1000, 2) as avg_duration',
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate', 'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
'sum(sign) as sessions', 'sum(sign) as sessions',
'sum(revenue * sign) as revenue',
]) ])
.from(TABLE_NAMES.sessions, true) .from(TABLE_NAMES.sessions, true)
.where('project_id', '=', projectId) .where('project_id', '=', projectId)
@@ -566,12 +591,14 @@ export class OverviewService {
sessions: number; sessions: number;
bounce_rate: number; bounce_rate: number;
avg_session_duration: number; avg_session_duration: number;
revenue: number;
}>([ }>([
prefixColumn && `${prefixColumn} as prefix`, prefixColumn && `${prefixColumn} as prefix`,
`nullIf(${column}, '') as name`, `nullIf(${column}, '') as name`,
'sum(sign) as sessions', 'sum(sign) as sessions',
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) AS bounce_rate', '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', 'round(avgIf(duration, duration > 0 AND sign > 0), 2)/1000 AS avg_session_duration',
'sum(revenue * sign) as revenue',
]) ])
.from(TABLE_NAMES.sessions, true) .from(TABLE_NAMES.sessions, true)
.where('project_id', '=', projectId) .where('project_id', '=', projectId)

View File

@@ -28,6 +28,7 @@ export type IProfileMetrics = {
avgEventsPerSession: number; avgEventsPerSession: number;
conversionEvents: number; conversionEvents: number;
avgTimeBetweenSessions: number; avgTimeBetweenSessions: number;
revenue: number;
}; };
export function getProfileMetrics(profileId: string, projectId: string) { export function getProfileMetrics(profileId: string, projectId: string) {
return chQuery< return chQuery<
@@ -76,6 +77,9 @@ export function getProfileMetrics(profileId: string, projectId: string) {
WHEN (SELECT sessions FROM sessions) <= 1 THEN 0 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) ELSE round(dateDiff('second', (SELECT firstSeen FROM firstSeen), (SELECT lastSeen FROM lastSeen)) / nullIf((SELECT sessions FROM sessions) - 1, 0), 1)
END as avgTimeBetweenSessions 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
(SELECT lastSeen FROM lastSeen) as lastSeen, (SELECT lastSeen FROM lastSeen) as lastSeen,
@@ -89,7 +93,8 @@ export function getProfileMetrics(profileId: string, projectId: string) {
(SELECT bounceRate FROM bounceRate) as bounceRate, (SELECT bounceRate FROM bounceRate) as bounceRate,
(SELECT avgEventsPerSession FROM avgEventsPerSession) as avgEventsPerSession, (SELECT avgEventsPerSession FROM avgEventsPerSession) as avgEventsPerSession,
(SELECT conversionEvents FROM conversionEvents) as conversionEvents, (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) => data[0]!)
.then((data) => { .then((data) => {

View File

@@ -209,12 +209,27 @@ export function sessionConsistency() {
// Since the check probably goes to the primary anyways it will always be true, // 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. // Not sure how to check LSN on the actual replica that will be used for the read.
if ( if (
model !== 'Session' &&
isReadOperation(operation) && isReadOperation(operation) &&
sessionId && sessionId &&
(await getCachedWalLsn(sessionId)) (await getCachedWalLsn(sessionId))
) { ) {
// This will force readReplicas extension to use primary const MAX_RETRIES = 3;
__internalParams.transaction = true; 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); return query(args);

View File

@@ -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() { clear() {
this.profileId = undefined; this.profileId = undefined;
// should we force a session end here? // should we force a session end here?

View File

@@ -20,9 +20,15 @@ function toCamelCase(str: string) {
); );
} }
type PendingRevenue = {
amount: number;
properties?: Record<string, unknown>;
};
export class OpenPanel extends OpenPanelBase { export class OpenPanel extends OpenPanelBase {
private lastPath = ''; private lastPath = '';
private debounceTimer: any; private debounceTimer: any;
private pendingRevenues: PendingRevenue[] = [];
constructor(public options: OpenPanelOptions) { constructor(public options: OpenPanelOptions) {
super({ super({
@@ -34,6 +40,18 @@ export class OpenPanel extends OpenPanelBase {
if (!this.isServer()) { if (!this.isServer()) {
console.log('OpenPanel.dev - Initialized', this.options); 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({ this.setGlobalProperties({
__referrer: document.referrer, __referrer: document.referrer,
}); });
@@ -191,4 +209,33 @@ export class OpenPanel extends OpenPanelBase {
__title: document.title, __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 {}
}
}
} }

View File

@@ -8,7 +8,11 @@ type ExposedMethodsNames =
| 'alias' | 'alias'
| 'increment' | 'increment'
| 'decrement' | 'decrement'
| 'clear'; | 'clear'
| 'revenue'
| 'flushRevenue'
| 'clearRevenue'
| 'pendingRevenue';
export type ExposedMethods = { export type ExposedMethods = {
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any [K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any

View File

@@ -60,13 +60,18 @@ export const chartRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input: { projectId } }) => { .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 `SELECT
uniqHLL12(profile_id) as value, uniqHLL12(profile_id) as value,
toStartOfDay(created_at) as date toStartOfDay(created_at) as date,
sum(revenue * sign) as revenue
FROM ${TABLE_NAMES.sessions} FROM ${TABLE_NAMES.sessions}
WHERE WHERE
sign = 1 AND
project_id = ${sqlstring.escape(projectId)} AND project_id = ${sqlstring.escape(projectId)} AND
created_at >= now() - interval '3 month' created_at >= now() - interval '3 month'
GROUP BY date GROUP BY date
@@ -74,22 +79,25 @@ export const chartRouter = createTRPCRouter({
WITH FILL FROM toStartOfDay(now() - interval '1 month') WITH FILL FROM toStartOfDay(now() - interval '1 month')
TO toStartOfDay(now()) TO toStartOfDay(now())
STEP INTERVAL 1 day STEP INTERVAL 1 day
SETTINGS session_timezone = '${timezone}'
`, `,
); );
const metricsPromise = clix(ch) const metricsPromise = clix(ch, timezone)
.select<{ .select<{
months_3: number; months_3: number;
months_3_prev: number; months_3_prev: number;
month: number; month: number;
day: number; day: number;
day_prev: 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(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(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() - 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(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', '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) .from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId) .where('project_id', '=', projectId)
@@ -207,6 +215,7 @@ export const chartRouter = createTRPCRouter({
} }
properties.push( properties.push(
'revenue',
'has_profile', 'has_profile',
'path', 'path',
'origin', 'origin',

View File

@@ -103,7 +103,6 @@ export const overviewRouter = createTRPCRouter({
]) ])
.from(TABLE_NAMES.events) .from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId) .where('project_id', '=', input.projectId)
.where('name', '=', 'session_start')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')); .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'));
// Get counts per minute for the last 30 minutes // Get counts per minute for the last 30 minutes
@@ -119,7 +118,6 @@ export const overviewRouter = createTRPCRouter({
]) ])
.from(TABLE_NAMES.events) .from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId) .where('project_id', '=', input.projectId)
.where('name', 'IN', ['session_start', 'screen_view'])
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')) .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.groupBy(['minute']) .groupBy(['minute'])
.orderBy('minute', 'ASC') .orderBy('minute', 'ASC')
@@ -138,11 +136,10 @@ export const overviewRouter = createTRPCRouter({
}>([ }>([
`${clix.toStartOf('created_at', 'minute')} as minute`, `${clix.toStartOf('created_at', 'minute')} as minute`,
'referrer_name', 'referrer_name',
'count(*) as count', 'uniq(session_id) as count',
]) ])
.from(TABLE_NAMES.events) .from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId) .where('project_id', '=', input.projectId)
.where('name', '=', 'session_start')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')) .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('referrer_name', '!=', '') .where('referrer_name', '!=', '')
.where('referrer_name', 'IS NOT NULL') .where('referrer_name', 'IS NOT NULL')
@@ -154,11 +151,10 @@ export const overviewRouter = createTRPCRouter({
const referrersQuery = clix(ch, timezone) const referrersQuery = clix(ch, timezone)
.select<{ referrer: string; count: number }>([ .select<{ referrer: string; count: number }>([
'referrer_name as referrer', 'referrer_name as referrer',
'count(*) as count', 'uniq(session_id) as count',
]) ])
.from(TABLE_NAMES.events) .from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId) .where('project_id', '=', input.projectId)
.where('name', '=', 'session_start')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')) .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('referrer_name', '!=', '') .where('referrer_name', '!=', '')
.where('referrer_name', 'IS NOT NULL') .where('referrer_name', 'IS NOT NULL')
@@ -234,6 +230,7 @@ export const overviewRouter = createTRPCRouter({
previous?.metrics.avg_session_duration || null, previous?.metrics.avg_session_duration || null,
prev_views_per_session: previous?.metrics.views_per_session || null, prev_views_per_session: previous?.metrics.views_per_session || null,
prev_total_sessions: previous?.metrics.total_sessions || null, prev_total_sessions: previous?.metrics.total_sessions || null,
prev_total_revenue: previous?.metrics.total_revenue || null,
}, },
series: current.series.map((item, index) => { series: current.series.map((item, index) => {
const prev = previous?.series[index]; const prev = previous?.series[index];
@@ -246,6 +243,7 @@ export const overviewRouter = createTRPCRouter({
prev_avg_session_duration: prev?.avg_session_duration, prev_avg_session_duration: prev?.avg_session_duration,
prev_views_per_session: prev?.views_per_session, prev_views_per_session: prev?.views_per_session,
prev_total_sessions: prev?.total_sessions, prev_total_sessions: prev?.total_sessions,
prev_total_revenue: prev?.total_revenue,
}; };
}), }),
}; };

View File

@@ -84,6 +84,7 @@ export const projectRouter = createTRPCRouter({
input.cors === undefined input.cors === undefined
? undefined ? undefined
: input.cors.map((c) => stripTrailingSlash(c)) || [], : input.cors.map((c) => stripTrailingSlash(c)) || [],
allowUnsafeRevenueTracking: input.allowUnsafeRevenueTracking,
}, },
include: { include: {
clients: { clients: {
@@ -123,6 +124,7 @@ export const projectRouter = createTRPCRouter({
domain: input.domain, domain: input.domain,
cors: input.cors, cors: input.cors,
crossDomain: false, crossDomain: false,
allowUnsafeRevenueTracking: false,
filters: [], filters: [],
clients: { clients: {
create: data, create: data,

View File

@@ -191,7 +191,7 @@ export const cacheMiddleware = (
key += JSON.stringify(rawInput).replace(/\"/g, "'"); key += JSON.stringify(rawInput).replace(/\"/g, "'");
} }
const cache = await getRedisCache().getJson(key); const cache = await getRedisCache().getJson(key);
if (cache) { if (cache && process.env.NODE_ENV === 'production') {
return { return {
ok: true, ok: true,
data: cache, data: cache,

View File

@@ -379,6 +379,7 @@ export const zProject = z.object({
domain: z.string().url().or(z.literal('').or(z.null())), domain: z.string().url().or(z.literal('').or(z.null())),
cors: z.array(z.string()).default([]), cors: z.array(z.string()).default([]),
crossDomain: z.boolean().default(false), crossDomain: z.boolean().default(false),
allowUnsafeRevenueTracking: z.boolean().default(false),
}); });
export type IProjectEdit = z.infer<typeof zProject>; export type IProjectEdit = z.infer<typeof zProject>;