This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-19 15:13:44 +01:00
parent 47adf46625
commit 41993d3463
35 changed files with 1098 additions and 233 deletions

View File

@@ -1,5 +1,7 @@
# CLAUDE.md
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview

View File

@@ -0,0 +1,28 @@
{
"permissions": {
"allow": [
"mcp__acp__Bash",
"mcp__acp__Edit",
"mcp__gsc__gsc_pages_stats",
"mcp__gsc__gsc_queries_list",
"mcp__gsc__gsc_pages_list",
"mcp__gsc__gsc_queries_stats",
"mcp__gsc__gsc_queries_opportunities",
"mcp__gsc__spyfu_domain_compare",
"mcp__gsc__spyfu_seo_keywords",
"mcp__gsc__gsc_devices_list",
"mcp__acp__Write",
"mcp__gsc__spyfu_serp_analysis",
"mcp__gsc__spyfu_keyword_info",
"mcp__gsc__serpapi_search",
"mcp__searchapi__google_rank_tracking",
"WebFetch(domain:www.mparticle.com)",
"WebFetch(domain:posthog.com)",
"WebFetch(domain:openpanel.dev)",
"mcp__gsc__gsc_sites_list",
"WebFetch(domain:plausible.io)",
"WebFetch(domain:opennext.js.org)",
"WebFetch(domain:clickhouse.com)"
]
}
}

View File

@@ -5,11 +5,9 @@ import { HttpError } from '@/utils/errors';
import { generateId, slug } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import {
TABLE_NAMES,
ch,
getProfileById,
getSalts,
sessionBuffer,
replayBuffer,
upsertProfile,
} from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
@@ -328,12 +326,7 @@ async function handleReplay(
is_full_snapshot: payload.is_full_snapshot,
payload: payload.payload,
};
await ch.insert({
table: TABLE_NAMES.session_replay_chunks,
values: [row],
format: 'JSONEachRow',
});
await sessionBuffer.markHasReplay(row.session_id);
await replayBuffer.add(row);
}
export async function handler(

View File

@@ -1,20 +1,20 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isDuplicatedEvent } from '@/utils/deduplicate';
export async function duplicateHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>,
reply: FastifyReply,
reply: FastifyReply
) {
const ip = req.clientIp;
const origin = req.headers.origin;
const clientId = req.headers['openpanel-client-id'];
const shouldCheck = ip && origin && clientId;
const shouldCheck = ip && origin && clientId && req.body.type !== 'replay';
const isDuplicate = shouldCheck
? await isDuplicatedEvent({
@@ -25,6 +25,7 @@ export async function duplicateHook(
})
: false;
console.log('Duplicate event', isDuplicate);
if (isDuplicate) {
return reply.status(200).send('Duplicate event');
}

View File

@@ -1,4 +1,3 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda';
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS'];
const getTrpcInput = (
request: FastifyRequest,
request: FastifyRequest
): Record<string, unknown> | undefined => {
const input = path<any>(['query', 'input'], request);
try {
@@ -18,7 +17,7 @@ const getTrpcInput = (
export async function requestLoggingHook(
request: FastifyRequest,
reply: FastifyReply,
reply: FastifyReply
) {
if (ignoreMethods.includes(request.method)) {
return;
@@ -40,9 +39,9 @@ export async function requestLoggingHook(
elapsed: reply.elapsedTime,
headers: pick(
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
request.headers,
request.headers
),
body: request.body,
// body: request.body,
});
}
}

View File

@@ -3,12 +3,12 @@ process.env.TZ = 'UTC';
import compress from '@fastify/compress';
import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import {
decodeSessionToken,
EMPTY_SESSION,
type SessionValidationResult,
validateSessionToken,
} from '@openpanel/auth';
import { generateId } from '@openpanel/common';
import {
type IServiceClientWithProject,
@@ -17,13 +17,11 @@ import {
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import {
EMPTY_SESSION,
type SessionValidationResult,
decodeSessionToken,
validateSessionToken,
} from '@openpanel/auth';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import sourceMapSupport from 'source-map-support';
import {
healthcheck,
@@ -72,7 +70,7 @@ const startServer = async () => {
try {
const fastify = Fastify({
maxParamLength: 15_000,
bodyLimit: 1048576 * 500, // 500MB
bodyLimit: 1_048_576 * 500, // 500MB
loggerInstance: logger as unknown as FastifyBaseLogger,
disableRequestLogging: true,
genReqId: (req) =>
@@ -84,7 +82,7 @@ const startServer = async () => {
fastify.register(cors, () => {
return (
req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void,
callback: (error: Error | null, options: FastifyCorsOptions) => void
) => {
// TODO: set prefix on dashboard routes
const corsPaths = [
@@ -97,7 +95,7 @@ const startServer = async () => {
];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path),
req.url.startsWith(path)
);
if (isPrivatePath) {
@@ -118,6 +116,7 @@ const startServer = async () => {
return callback(null, {
origin: '*',
maxAge: 86_400 * 7, // cache preflight for 24h
});
};
});
@@ -126,6 +125,11 @@ const startServer = async () => {
global: false,
});
fastify.addHook('onRequest', async (req) => {
if (req.method === 'POST') {
console.log('Incoming req', req.method, req.url);
}
});
fastify.addHook('onRequest', requestIdHook);
fastify.addHook('onRequest', timestampHook);
fastify.addHook('onRequest', ipHook);
@@ -149,7 +153,7 @@ const startServer = async () => {
try {
const sessionId = decodeSessionToken(req.cookies?.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
validateSessionToken(req.cookies.session)
);
req.session = session;
} catch (e) {
@@ -158,7 +162,7 @@ const startServer = async () => {
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null),
validateSessionToken(null)
);
req.session = session;
} catch (e) {
@@ -173,7 +177,7 @@ const startServer = async () => {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext: createContext,
createContext,
onError(ctx) {
if (
ctx.error.code === 'UNAUTHORIZED' &&
@@ -217,7 +221,7 @@ const startServer = async () => {
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
}),
})
);
});
@@ -274,7 +278,7 @@ const startServer = async () => {
} catch (error) {
logger.warn('Failed to set redis notify-keyspace-events', error);
logger.warn(
'If you use a managed Redis service, you may need to set this manually.',
'If you use a managed Redis service, you may need to set this manually.'
);
logger.warn('Otherwise some functions may not work as expected.');
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,337 @@
(() => {
var u = class {
constructor(e) {
(this.baseUrl = e.baseUrl),
(this.headers = {
'Content-Type': 'application/json',
...e.defaultHeaders,
}),
(this.maxRetries = e.maxRetries ?? 3),
(this.initialRetryDelay = e.initialRetryDelay ?? 500);
}
async resolveHeaders() {
const e = {};
for (const [i, t] of Object.entries(this.headers)) {
const s = await t;
s !== null && (e[i] = s);
}
return e;
}
addHeader(e, i) {
this.headers[e] = i;
}
async post(e, i, t, s) {
try {
const n = await fetch(e, {
method: 'POST',
headers: await this.resolveHeaders(),
body: i ? JSON.stringify(i ?? {}) : void 0,
keepalive: !0,
...t,
});
if (n.status === 401) {
return null;
}
if (n.status !== 200 && n.status !== 202) {
throw new Error(`HTTP error! status: ${n.status}`);
}
const r = await n.text();
return r ? JSON.parse(r) : null;
} catch (n) {
if (s < this.maxRetries) {
const r = this.initialRetryDelay * 2 ** s;
return (
await new Promise((a) => setTimeout(a, r)),
this.post(e, i, t, s + 1)
);
}
return console.error('Max retries reached:', n), null;
}
}
async fetch(e, i, t = {}) {
const s = `${this.baseUrl}${e}`;
return this.post(s, i, t, 0);
}
},
l = class {
constructor(e) {
(this.options = e), (this.queue = []);
const i = { 'openpanel-client-id': e.clientId };
e.clientSecret && (i['openpanel-client-secret'] = e.clientSecret),
(i['openpanel-sdk-name'] = e.sdk || 'node'),
(i['openpanel-sdk-version'] = e.sdkVersion || '1.0.3'),
(this.api = new u({
baseUrl: e.apiUrl || 'https://api.openpanel.dev',
defaultHeaders: i,
}));
}
init() {}
ready() {
(this.options.waitForProfile = !1), this.flush();
}
async send(e) {
return this.options.disabled ||
(this.options.filter && !this.options.filter(e))
? Promise.resolve()
: this.options.waitForProfile && !this.profileId
? (this.queue.push(e), Promise.resolve())
: this.api.fetch('/track', e);
}
setGlobalProperties(e) {
this.global = { ...this.global, ...e };
}
async track(e, i) {
return (
this.log('track event', e, i),
this.send({
type: 'track',
payload: {
name: e,
profileId: i?.profileId ?? this.profileId,
properties: { ...(this.global ?? {}), ...(i ?? {}) },
},
})
);
}
async identify(e) {
if (
(this.log('identify user', e),
e.profileId && ((this.profileId = e.profileId), this.flush()),
Object.keys(e).length > 1)
) {
return this.send({
type: 'identify',
payload: { ...e, properties: { ...this.global, ...e.properties } },
});
}
}
async alias(e) {}
async increment(e) {
return this.send({ type: 'increment', payload: e });
}
async decrement(e) {
return this.send({ type: 'decrement', payload: e });
}
async revenue(e, i) {
const t = i?.deviceId;
return (
delete i?.deviceId,
this.track('revenue', {
...(i ?? {}),
...(t ? { __deviceId: t } : {}),
__revenue: e,
})
);
}
async fetchDeviceId() {
return (
(
await this.api.fetch('/track/device-id', void 0, {
method: 'GET',
keepalive: !1,
})
)?.deviceId ?? ''
);
}
clear() {
this.profileId = void 0;
}
flush() {
this.queue.forEach((e) => {
this.send({
...e,
payload: {
...e.payload,
profileId: e.payload.profileId ?? this.profileId,
},
});
}),
(this.queue = []);
}
log(...e) {
this.options.debug && console.log('[OpenPanel.dev]', ...e);
}
};
function h(e) {
return e.replace(/([-_][a-z])/gi, (i) =>
i.toUpperCase().replace('-', '').replace('_', '')
);
}
var p = class extends l {
constructor(t) {
super({ sdk: 'web', sdkVersion: '1.0.6', ...t });
this.options = t;
this.lastPath = '';
this.pendingRevenues = [];
if (!this.isServer()) {
try {
const s = sessionStorage.getItem('openpanel-pending-revenues');
if (s) {
const n = JSON.parse(s);
Array.isArray(n) && (this.pendingRevenues = n);
}
} catch {
this.pendingRevenues = [];
}
this.setGlobalProperties({ __referrer: document.referrer }),
this.options.trackScreenViews &&
(this.trackScreenViews(), setTimeout(() => this.screenView(), 0)),
this.options.trackOutgoingLinks && this.trackOutgoingLinks(),
this.options.trackAttributes && this.trackAttributes();
}
}
debounce(t, s) {
clearTimeout(this.debounceTimer), (this.debounceTimer = setTimeout(t, s));
}
isServer() {
return typeof document > 'u';
}
trackOutgoingLinks() {
this.isServer() ||
document.addEventListener('click', (t) => {
const s = t.target,
n = s.closest('a');
if (n && s) {
const r = n.getAttribute('href');
if (r?.startsWith('http')) {
try {
const a = new URL(r),
o = window.location.hostname;
a.hostname !== o &&
super.track('link_out', {
href: r,
text:
n.innerText ||
n.getAttribute('title') ||
s.getAttribute('alt') ||
s.getAttribute('title'),
});
} catch {}
}
}
});
}
trackScreenViews() {
if (this.isServer()) {
return;
}
const t = history.pushState;
history.pushState = function (...a) {
const o = t.apply(this, a);
return (
window.dispatchEvent(new Event('pushstate')),
window.dispatchEvent(new Event('locationchange')),
o
);
};
const s = history.replaceState;
(history.replaceState = function (...a) {
const o = s.apply(this, a);
return (
window.dispatchEvent(new Event('replacestate')),
window.dispatchEvent(new Event('locationchange')),
o
);
}),
window.addEventListener('popstate', () => {
window.dispatchEvent(new Event('locationchange'));
});
const n = () => this.debounce(() => this.screenView(), 50);
this.options.trackHashChanges
? window.addEventListener('hashchange', n)
: window.addEventListener('locationchange', n);
}
trackAttributes() {
this.isServer() ||
document.addEventListener('click', (t) => {
const s = t.target,
n = s.closest('button'),
r = s.closest('a'),
a = n?.getAttribute('data-track')
? n
: r?.getAttribute('data-track')
? r
: null;
if (a) {
const o = {};
for (const c of a.attributes) {
c.name.startsWith('data-') &&
c.name !== 'data-track' &&
(o[h(c.name.replace(/^data-/, ''))] = c.value);
}
const d = a.getAttribute('data-track');
d && super.track(d, o);
}
});
}
screenView(t, s) {
if (this.isServer()) {
return;
}
let n, r;
typeof t == 'string'
? ((n = t), (r = s))
: ((n = window.location.href), (r = t)),
this.lastPath !== n &&
((this.lastPath = n),
super.track('screen_view', {
...(r ?? {}),
__path: n,
__title: document.title,
}));
}
async flushRevenue() {
const t = this.pendingRevenues.map((s) =>
super.revenue(s.amount, s.properties)
);
await Promise.all(t), this.clearRevenue();
}
clearRevenue() {
if (((this.pendingRevenues = []), !this.isServer())) {
try {
sessionStorage.removeItem('openpanel-pending-revenues');
} catch {}
}
}
pendingRevenue(t, s) {
if (
(this.pendingRevenues.push({ amount: t, properties: s }),
!this.isServer())
) {
try {
sessionStorage.setItem(
'openpanel-pending-revenues',
JSON.stringify(this.pendingRevenues)
);
} catch {}
}
}
};
((e) => {
if (e.op) {
const i = e.op.q || [],
t = new p(i.shift()[1]);
i.forEach((n) => {
n[0] in t && t[n[0]](...n.slice(1));
});
const s = new Proxy(
(n, ...r) => {
const a = t[n] ? t[n].bind(t) : void 0;
typeof a == 'function'
? a(...r)
: console.warn(`OpenPanel: ${n} is not a function`);
},
{
get(n, r) {
if (r === 'q') {
return;
}
const a = t[r];
return typeof a == 'function' ? a.bind(t) : a;
},
}
);
(e.op = s), (e.openpanel = t);
}
})(window);
})();

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { getRootMetadata } from '@/lib/metadata';
import { cn } from '@/lib/utils';
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { TooltipProvider } from '@/components/ui/tooltip';
import { getRootMetadata } from '@/lib/metadata';
import { cn } from '@/lib/utils';
import './global.css';
import { OpenPanelComponent } from '@openpanel/nextjs';
@@ -31,22 +31,20 @@ export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={cn(font.className, mono.variable)}
lang="en"
suppressHydrationWarning
>
<body className="flex flex-col min-h-screen bg-background">
<body className="flex min-h-screen flex-col bg-background">
<RootProvider>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
<OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
trackAttributes
trackScreenViews
trackOutgoingLinks
trackScreenViews
/>
)}
</body>

View File

@@ -38,9 +38,10 @@
"@openpanel/integrations": "workspace:^",
"@openpanel/json": "workspace:*",
"@openpanel/payments": "workspace:*",
"@openpanel/sdk": "^1.0.8",
"@openpanel/sdk-info": "workspace:^",
"@openpanel/validation": "workspace:^",
"@openpanel/web": "^1.0.1",
"@openpanel/web": "^1.0.12",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -116,7 +117,6 @@
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"rrweb-player": "2.0.0-alpha.20",
"rc-virtual-list": "^3.14.5",
"react": "catalog:",
"react-animate-height": "^3.2.3",
@@ -142,6 +142,7 @@
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"rrweb-player": "2.0.0-alpha.20",
"short-unique-id": "^5.0.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",

View File

@@ -69,11 +69,11 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
className="h-full"
>
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
<div className="flex flex-col">
<div className="flex w-full flex-col">
{visibleEvents.map(({ event, offsetMs }) => (
<div
key={event.id}
className="animate-in fade-in-0 slide-in-from-bottom-3 duration-300 fill-mode-both"
className="animate-in fade-in-0 slide-in-from-bottom-3 min-w-0 duration-300 fill-mode-both"
>
<ReplayEventItem
event={event}

View File

@@ -1,21 +1,17 @@
'use client';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import type { IServiceEvent } from '@openpanel/db';
function formatOffset(ms: number): string {
const sign = ms < 0 ? '-' : '+';
const abs = Math.abs(ms);
const totalSeconds = Math.floor(abs / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${sign}${m}:${s.toString().padStart(2, '0')}`;
function formatTime(date: Date | string): string {
const d = date instanceof Date ? date : new Date(date);
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
const s = d.getSeconds().toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
export function ReplayEventItem({
event,
offsetMs,
isCurrent,
onClick,
}: {
@@ -48,7 +44,7 @@ export function ReplayEventItem({
</div>
</div>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatOffset(offsetMs)}
{formatTime(event.createdAt)}
</span>
</div>
</button>

View File

@@ -48,7 +48,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
ref={ref}
>
{children}

View File

@@ -2,7 +2,8 @@ import { useLocation } from '@tanstack/react-router';
export function usePageTabs(tabs: { id: string; label: string }[]) {
const location = useLocation();
const tab = location.pathname.split('/').pop();
const segments = location.pathname.split('/').filter(Boolean);
const tab = segments[segments.length - 1];
if (!tab) {
return {

View File

@@ -83,6 +83,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from '.
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute(
@@ -557,6 +558,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
} as any)
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
id: '/events',
@@ -633,6 +640,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRoutesByTo {
@@ -695,6 +703,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -778,6 +787,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
export interface FileRouteTypes {
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
| '/$organizationId/$projectId/profiles/$profileId/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -913,6 +924,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/profiles/$profileId/events'
| '/$organizationId/$projectId/profiles/$profileId/sessions'
id:
| '__root__'
| '/'
@@ -995,6 +1007,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
fileRoutesById: FileRoutesById
}
@@ -1578,6 +1591,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
path: '/sessions'
fullPath: '/$organizationId/$projectId/profiles/$profileId/sessions'
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
}
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': {
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
path: '/events'
@@ -1689,6 +1709,7 @@ const AppOrganizationIdProjectIdProfilesTabsRouteWithChildren =
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
}
@@ -1696,6 +1717,8 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren: AppOrganizat
{
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute,
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute,
}

View File

@@ -0,0 +1,33 @@
import { SessionsTable } from '@/components/sessions/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions',
)({
component: Component,
});
function Component() {
const { projectId, profileId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const query = useInfiniteQuery(
trpc.session.list.infiniteQueryOptions(
{
projectId,
profileId,
take: 50,
search: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
return <SessionsTable query={query} />;
}

View File

@@ -43,6 +43,7 @@ function Component() {
label: 'Overview',
},
{ id: 'events', label: 'Events' },
{ id: 'sessions', label: 'Sessions' },
]);
const handleTabChange = (tabId: string) => {

View File

@@ -1,19 +1,20 @@
import { EventsTable } from '@/components/events/table';
import { EventIcon } from '@/components/events/event-icon';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { ReplayShell } from '@/components/sessions/replay';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody, WidgetHead, WidgetTitle } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { useNumber } from '@/hooks/use-numer-formatter';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Link, createFileRoute } from '@tanstack/react-router';
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/sessions_/$sessionId',
@@ -27,101 +28,353 @@ export const Route = createFileRoute(
projectId: params.projectId,
}),
),
context.queryClient.prefetchQuery(
context.trpc.event.events.queryOptions({
projectId: params.projectId,
sessionId: params.sessionId,
filters: [],
columnVisibility: {},
}),
),
]);
},
head: () => {
return {
meta: [
{
title: createProjectTitle('Sessions'),
},
],
};
},
head: () => ({
meta: [{ title: createProjectTitle('Session') }],
}),
pendingComponent: FullPageLoadingState,
});
function Component() {
const { projectId, sessionId } = Route.useParams();
const trpc = useTRPC();
function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
return session as unknown as IServiceEvent;
}
const LIMIT = 50;
function VisitedRoutes({ paths }: { paths: string[] }) {
const counted = paths.reduce<Record<string, number>>((acc, p) => {
acc[p] = (acc[p] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
const max = sorted[0]?.[1] ?? 1;
if (sorted.length === 0) return null;
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Visited pages</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{sorted.map(([path, count]) => (
<div key={path} className="relative px-3 py-2 group">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200 group-hover:bg-def-300"
style={{ width: `${(count / max) * 100}%` }}
/>
<div className="relative flex justify-between gap-2 min-w-0">
<span className="truncate text-sm">{path}</span>
<span className="shrink-0 text-sm font-medium">{count}</span>
</div>
</div>
))}
</div>
</Widget>
);
}
function EventDistribution({ events }: { events: IServiceEvent[] }) {
const counted = events.reduce<Record<string, number>>((acc, e) => {
acc[e.name] = (acc[e.name] ?? 0) + 1;
return acc;
}, {});
const sorted = Object.entries(counted).sort((a, b) => b[1] - a[1]);
const max = sorted[0]?.[1] ?? 1;
if (sorted.length === 0) return null;
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Event distribution</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{sorted.map(([name, count]) => (
<div key={name} className="relative px-3 py-2 group">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200 group-hover:bg-def-300"
style={{ width: `${(count / max) * 100}%` }}
/>
<div className="relative flex justify-between gap-2">
<span className="text-sm">{name.replace(/_/g, ' ')}</span>
<span className="shrink-0 text-sm font-medium">{count}</span>
</div>
</div>
))}
</div>
</Widget>
);
}
function Component() {
const { projectId, sessionId, organizationId } = Route.useParams();
const trpc = useTRPC();
const number = useNumber()
const { data: session } = useSuspenseQuery(
trpc.session.byId.queryOptions({
trpc.session.byId.queryOptions({ sessionId, projectId }),
);
const { data: eventsData } = useSuspenseQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
}),
);
const events = eventsData?.data ?? [];
const isIdentified =
session.profileId && session.profileId !== session.deviceId;
const { data: profile } = useSuspenseQuery(
trpc.profile.byId.queryOptions({
profileId: session.profileId,
projectId,
}),
);
const [filters] = useEventQueryFilters();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
sessionId,
filters,
events: eventNames,
startDate: startDate || undefined,
endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
);
const fakeEvent = sessionToFakeEvent(session);
return (
<PageContainer>
<PageContainer className="col gap-8">
<PageHeader
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
title={`Session: ${session.id}`}
>
<div className="row gap-4 mb-6">
{session.country && (
<div className="row gap-2 items-center">
<SerieIcon name={session.country} />
<span>
{session.country}
{session.city && ` / ${session.city}`}
</span>
</div>
)}
{session.device && (
<div className="row gap-2 items-center">
<SerieIcon name={session.device} />
<span className="capitalize">{session.device}</span>
</div>
)}
{session.os && (
<div className="row gap-2 items-center">
<SerieIcon name={session.os} />
<span>{session.os}</span>
</div>
)}
{session.model && (
<div className="row gap-2 items-center">
<SerieIcon name={session.model} />
<span>{session.model}</span>
</div>
)}
{session.country && (
<div className="row gap-2 items-center">
<SerieIcon name={session.country} />
<span>
{session.country}
{session.city && ` / ${session.city}`}
</span>
</div>
)}
{session.device && (
<div className="row gap-2 items-center">
<SerieIcon name={session.device} />
<span className="capitalize">{session.device}</span>
</div>
)}
{session.os && (
<div className="row gap-2 items-center">
<SerieIcon name={session.os} />
<span>{session.os}</span>
</div>
)}
{session.model && (
<div className="row gap-2 items-center">
<SerieIcon name={session.model} />
<span>{session.model}</span>
</div>
)}
{session.browser && (
<div className="row gap-2 items-center">
<SerieIcon name={session.browser} />
<span>{session.browser}</span>
</div>
)}
</div>
</div>
</PageHeader>
<div className="mb-6">
<ReplayShell sessionId={sessionId} projectId={projectId} />
{session.hasReplay && <ReplayShell sessionId={sessionId} projectId={projectId} />}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
{/* Left column */}
<div className="col gap-6">
{/* Session info */}
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Session info</WidgetTitle>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={1}
copyable
data={[
{ name: 'duration', value: number.formatWithUnit(session.duration/1000, 'min') },
{ name: 'createdAt', value: session.createdAt },
{ name: 'endedAt', value: session.endedAt },
{ name: 'screenViews', value: session.screenViewCount },
{ name: 'events', value: session.eventCount },
{ name: 'bounce', value: session.isBounce ? 'Yes' : 'No' },
...(session.entryPath
? [{ name: 'entryPath', value: session.entryPath }]
: []),
...(session.exitPath
? [{ name: 'exitPath', value: session.exitPath }]
: []),
...(session.referrerName
? [{ name: 'referrerName', value: session.referrerName }]
: []),
...(session.referrer
? [{ name: 'referrer', value: session.referrer }]
: []),
...(session.utmSource
? [{ name: 'utmSource', value: session.utmSource }]
: []),
...(session.utmMedium
? [{ name: 'utmMedium', value: session.utmMedium }]
: []),
...(session.utmCampaign
? [{ name: 'utmCampaign', value: session.utmCampaign }]
: []),
...(session.revenue > 0
? [{ name: 'revenue', value: `$${session.revenue}` }]
: []),
{ name: 'country', value: session.country, event: fakeEvent },
...(session.city
? [{ name: 'city', value: session.city, event: fakeEvent }]
: []),
...(session.os
? [{ name: 'os', value: session.os, event: fakeEvent }]
: []),
...(session.browser
? [
{
name: 'browser',
value: session.browser,
event: fakeEvent,
},
]
: []),
...(session.device
? [
{
name: 'device',
value: session.device,
event: fakeEvent,
},
]
: []),
...(session.brand
? [{ name: 'brand', value: session.brand, event: fakeEvent }]
: []),
...(session.model
? [{ name: 'model', value: session.model, event: fakeEvent }]
: []),
]}
/>
</Widget>
{/* Profile card */}
{isIdentified && profile && (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Profile</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
<Link
to="/$organizationId/$projectId/profiles/$profileId"
params={{
organizationId,
projectId,
profileId: session.profileId,
}}
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
>
<ProfileAvatar {...profile} size="lg" />
<div className="col min-w-0 gap-0.5">
<span className="truncate font-medium">
{getProfileName(profile, false) ?? session.profileId}
</span>
{profile.email && (
<span className="truncate text-sm text-muted-foreground">
{profile.email}
</span>
)}
</div>
</Link>
</WidgetBody>
</Widget>
)}
{/* Visited pages */}
<VisitedRoutes
paths={events
.filter((e) => e.name === 'screen_view' && e.path)
.map((e) => e.path)}
/>
{/* Event distribution */}
<EventDistribution events={events} />
</div>
{/* Right column */}
<div className="col gap-6">
{/* Events list */}
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Events</WidgetTitle>
</WidgetHead>
<div className="divide-y">
{events.map((event) => (
<div
key={event.id}
className="row items-center gap-3 px-4 py-2"
>
<EventIcon name={event.name} meta={event.meta} size="sm" />
<div className="col min-w-0 flex-1">
<span className="truncate text-sm font-medium">
{event.name === 'screen_view' && event.path
? event.path
: event.name.replace(/_/g, ' ')}
</span>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{formatDateTime(event.createdAt)}
</span>
</div>
))}
{events.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No events found
</div>
)}
</div>
</Widget>
</div>
</div>
<EventsTable query={query} />
</PageContainer>
);
}

View File

@@ -2,19 +2,14 @@ import { OpenPanel } from '@openpanel/web';
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
const createOpInstance = () => {
if (!clientId || clientId === 'undefined') {
return new Proxy({} as OpenPanel, {
get: () => () => {},
});
}
return new OpenPanel({
clientId,
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
};
export const op = createOpInstance();
export const op = new OpenPanel({
clientId,
disabled: clientId === 'undefined' || !clientId,
// apiUrl: 'http://localhost:3333',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
// sessionReplay: {
// enabled: true,
// }
});

View File

@@ -63,6 +63,11 @@ export async function bootCron() {
type: 'flushProfileBackfill',
pattern: 1000 * 30,
},
{
name: 'flush',
type: 'flushReplay',
pattern: 1000 * 10,
},
{
name: 'insightsDaily',
type: 'insightsDaily',

View File

@@ -1,6 +1,6 @@
import type { Job } from 'bullmq';
import { eventBuffer, profileBackfillBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
@@ -26,6 +26,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'flushProfileBackfill': {
return await profileBackfillBuffer.tryFlush();
}
case 'flushReplay': {
return await replayBuffer.tryFlush();
}
case 'ping': {
return await ping();
}

View File

@@ -4,6 +4,7 @@ import {
botBuffer,
eventBuffer,
profileBuffer,
replayBuffer,
sessionBuffer,
} from '@openpanel/db';
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
@@ -124,3 +125,14 @@ register.registerMetric(
},
}),
);
register.registerMetric(
new client.Gauge({
name: `buffer_${replayBuffer.name}_count`,
help: 'Number of unprocessed replay chunks',
async collect() {
const metric = await replayBuffer.getBufferSize();
this.set(metric);
},
}),
);

View File

@@ -34,11 +34,6 @@ export async function up() {
replicatedVersion: '1',
isClustered,
}),
...addColumns(
TABLE_NAMES.sessions,
['`has_replay` Bool DEFAULT 0'],
isClustered,
),
modifyTTL({
tableName: TABLE_NAMES.session_replay_chunks,
isClustered,

View File

@@ -2,6 +2,7 @@ import { BotBuffer as BotBufferRedis } from './bot-buffer';
import { EventBuffer as EventBufferRedis } from './event-buffer';
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
import { ReplayBuffer } from './replay-buffer';
import { SessionBuffer } from './session-buffer';
export const eventBuffer = new EventBufferRedis();
@@ -9,5 +10,7 @@ export const profileBuffer = new ProfileBufferRedis();
export const botBuffer = new BotBufferRedis();
export const sessionBuffer = new SessionBuffer();
export const profileBackfillBuffer = new ProfileBackfillBuffer();
export const replayBuffer = new ReplayBuffer();
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
export type { IClickhouseSessionReplayChunk } from './replay-buffer';

View File

@@ -0,0 +1,92 @@
import { getSafeJson } from '@openpanel/json';
import { getRedisCache } from '@openpanel/redis';
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { BaseBuffer } from './base-buffer';
export interface IClickhouseSessionReplayChunk {
project_id: string;
session_id: string;
chunk_index: number;
started_at: string;
ended_at: string;
events_count: number;
is_full_snapshot: boolean;
payload: string;
}
export class ReplayBuffer extends BaseBuffer {
private batchSize = process.env.REPLAY_BUFFER_BATCH_SIZE
? Number.parseInt(process.env.REPLAY_BUFFER_BATCH_SIZE, 10)
: 500;
private chunkSize = process.env.REPLAY_BUFFER_CHUNK_SIZE
? Number.parseInt(process.env.REPLAY_BUFFER_CHUNK_SIZE, 10)
: 500;
private readonly redisKey = 'replay-buffer';
constructor() {
super({
name: 'replay',
onFlush: async () => {
await this.processBuffer();
},
});
}
async add(chunk: IClickhouseSessionReplayChunk) {
try {
const redis = getRedisCache();
const result = await redis
.multi()
.rpush(this.redisKey, JSON.stringify(chunk))
.incr(this.bufferCounterKey)
.llen(this.redisKey)
.exec();
const bufferLength = (result?.[2]?.[1] as number) ?? 0;
if (bufferLength >= this.batchSize) {
await this.tryFlush();
}
} catch (error) {
this.logger.error('Failed to add replay chunk to buffer', { error });
}
}
async processBuffer() {
const redis = getRedisCache();
try {
const items = await redis.lrange(this.redisKey, 0, this.batchSize - 1);
if (items.length === 0) {
return;
}
const chunks = items.map((item) =>
getSafeJson<IClickhouseSessionReplayChunk>(item),
);
for (const chunk of this.chunks(chunks, this.chunkSize)) {
await ch.insert({
table: TABLE_NAMES.session_replay_chunks,
values: chunk,
format: 'JSONEachRow',
});
}
await redis
.multi()
.ltrim(this.redisKey, items.length, -1)
.decrby(this.bufferCounterKey, items.length)
.exec();
this.logger.debug('Processed replay chunks', { count: items.length });
} catch (error) {
this.logger.error('Failed to process replay buffer', { error });
}
}
async getBufferSize() {
const redis = getRedisCache();
return this.getBufferSizeWithCounter(() => redis.llen(this.redisKey));
}
}

View File

@@ -163,46 +163,10 @@ export class SessionBuffer extends BaseBuffer {
: '',
sign: 1,
version: 1,
has_replay: false,
},
];
}
async markHasReplay(sessionId: string): Promise<void> {
console.log('markHasReplay', sessionId);
const existingSession = await this.getExistingSession({ sessionId });
if (!existingSession) {
console.log('no existing session or has replay', existingSession);
return;
}
if (existingSession.has_replay) {
return;
}
const oldSession = assocPath(['sign'], -1, clone(existingSession));
const newSession = assocPath(['sign'], 1, clone(existingSession));
newSession.version = existingSession.version + 1;
newSession.has_replay = true;
const multi = this.redis.multi();
multi.set(
`session:${sessionId}`,
JSON.stringify(newSession),
'EX',
60 * 60,
);
multi.rpush(this.redisKey, JSON.stringify(newSession));
multi.rpush(this.redisKey, JSON.stringify(oldSession));
multi.incrby(this.bufferCounterKey, 2);
await multi.exec();
const bufferLength = await this.getBufferSize();
if (bufferLength >= this.batchSize) {
await this.tryFlush();
}
}
async add(event: IClickhouseEvent) {
if (!event.session_id) {
return;

View File

@@ -5,6 +5,7 @@ import {
TABLE_NAMES,
ch,
chQuery,
convertClickhouseDateToJs,
formatClickhouseDate,
} from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
@@ -52,7 +53,7 @@ export type IClickhouseSession = {
revenue: number;
sign: 1 | 0;
version: number;
has_replay?: boolean;
has_replay: boolean;
};
export interface IServiceSession {
@@ -116,8 +117,8 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
entryOrigin: session.entry_origin,
exitPath: session.exit_path,
exitOrigin: session.exit_origin,
createdAt: new Date(session.created_at),
endedAt: new Date(session.ended_at),
createdAt: convertClickhouseDateToJs(session.created_at),
endedAt: convertClickhouseDateToJs(session.ended_at),
referrer: session.referrer,
referrerName: session.referrer_name,
referrerType: session.referrer_type,
@@ -143,7 +144,7 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
utmContent: session.utm_content,
utmTerm: session.utm_term,
revenue: session.revenue,
hasReplay: session.has_replay ?? false,
hasReplay: session.has_replay,
profile: undefined,
};
}
@@ -230,13 +231,14 @@ export async function getSessionList({
'screen_view_count',
'event_count',
'revenue',
'has_replay',
];
columns.forEach((column) => {
sb.select[column] = column;
});
sb.select.has_replay = `exists(SELECT 1 FROM ${TABLE_NAMES.session_replay_chunks} WHERE session_id = id AND project_id = ${sqlstring.escape(projectId)}) as has_replay`;
const sql = getSql();
const data = await chQuery<
IClickhouseSession & {

View File

@@ -125,12 +125,17 @@ export type CronQueuePayloadFlushProfileBackfill = {
type: 'flushProfileBackfill';
payload: undefined;
};
export type CronQueuePayloadFlushReplay = {
type: 'flushReplay';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
| CronQueuePayloadFlushSessions
| CronQueuePayloadFlushProfiles
| CronQueuePayloadFlushProfileBackfill
| CronQueuePayloadFlushReplay
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily

View File

@@ -95,10 +95,11 @@ export class OpenPanel {
return Promise.resolve();
}
// Disable keepalive for replay since it has a hard body limit and breaks the request
const result = await this.api.fetch<
TrackHandlerPayload,
{ deviceId: string; sessionId: string }
>('/track', payload);
>('/track', payload, { keepalive: payload.type !== 'replay' });
this.deviceId = result?.deviceId;
const hadSession = !!this.sessionId;
this.sessionId = result?.sessionId;

View File

@@ -133,9 +133,8 @@ export class OpenPanel extends OpenPanelBase {
// string literal only in the IIFE build, so this branch is
// dead-code-eliminated in the library build.
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
// IIFE / script-tag context — load from CDN (or user override)
const url =
this.options.sessionReplay?.scriptUrl ?? __OPENPANEL_REPLAY_URL__;
const scriptEl = document.currentScript as HTMLScriptElement | null;
const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
// Already loaded (e.g. user included the script manually)
if ((window as any).__openpanel_replay) {

View File

@@ -42,15 +42,29 @@ export function startReplayRecorder(
function flush(isFullSnapshot: boolean): void {
if (buffer.length === 0) return;
const startedAt = buffer[0]!.timestamp;
const endedAt = buffer[buffer.length - 1]!.timestamp;
const payloadJson = JSON.stringify(buffer);
if (payloadJson.length > maxPayloadBytes) {
// If over size limit, split by taking only up to maxPayloadBytes (simplified: flush as-is and log or truncate)
// For MVP we still send; server will reject if over 1MB
if (buffer.length > 1) {
const mid = Math.floor(buffer.length / 2);
const firstHalf = buffer.slice(0, mid);
const secondHalf = buffer.slice(mid);
const firstHasFullSnapshot =
isFullSnapshot && firstHalf.some((e) => e.type === 2);
buffer = firstHalf;
flush(firstHasFullSnapshot);
buffer = secondHalf;
flush(false);
return;
}
// Single event exceeds limit — drop it to avoid server rejection
buffer = [];
return;
}
const startedAt = buffer[0]!.timestamp;
const endedAt = buffer[buffer.length - 1]!.timestamp;
sendChunk({
chunk_index: chunkIndex,
events_count: buffer.length,

View File

@@ -17,6 +17,8 @@ export default defineConfig([
// __OPENPANEL_REPLAY_URL__ is injected at build time so the IIFE
// knows to load the replay module from the CDN instead of a
// relative import (which doesn't work in a standalone script).
// The replay module is excluded via an esbuild plugin so it is
// never bundled into op1.js — it will be loaded lazily via <script>.
{
entry: { 'src/tracker': 'src/tracker.ts' },
format: ['iife'],
@@ -25,9 +27,30 @@ export default defineConfig([
minify: true,
define: {
__OPENPANEL_REPLAY_URL__: JSON.stringify(
'https://openpanel.dev/op1-replay.js',
'https://openpanel.dev/op1-replay.js'
),
},
esbuildPlugins: [
{
name: 'exclude-replay-from-iife',
setup(build) {
// Intercept any import that resolves to the replay module and
// return an empty object. The actual loading happens at runtime
// via a <script> tag (see loadReplayModule in index.ts).
build.onResolve(
{ filter: /[/\\]replay([/\\]index)?(\.[jt]s)?$/ },
() => ({
path: 'replay-empty-stub',
namespace: 'replay-stub',
})
);
build.onLoad({ filter: /.*/, namespace: 'replay-stub' }, () => ({
contents: 'module.exports = {}',
loader: 'js',
}));
},
},
],
},
// Replay module — built as both ESM (npm) and IIFE (CDN).
// ESM → consumed by the host-app's bundler via `import('./replay')`.

View File

@@ -64,12 +64,12 @@ export const zAliasPayload = z.object({
});
export const zReplayPayload = z.object({
chunk_index: z.number().int().min(0).max(65535),
chunk_index: z.number().int().min(0).max(65_535),
events_count: z.number().int().min(1),
is_full_snapshot: z.boolean(),
started_at: z.string(),
ended_at: z.string(),
payload: z.string().max(1_048_576), // 1MB max
payload: z.string().max(1_048_576 * 2), // 1MB max
});
export const zTrackHandlerPayload = z.discriminatedUnion('type', [

39
pnpm-lock.yaml generated
View File

@@ -465,6 +465,9 @@ importers:
'@openpanel/payments':
specifier: workspace:*
version: link:../../packages/payments
'@openpanel/sdk':
specifier: ^1.0.8
version: 1.0.8
'@openpanel/sdk-info':
specifier: workspace:^
version: link:../../packages/sdks/_info
@@ -472,8 +475,8 @@ importers:
specifier: workspace:^
version: link:../../packages/validation
'@openpanel/web':
specifier: ^1.0.1
version: 1.0.1
specifier: ^1.0.12
version: 1.0.12
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1481,7 +1484,7 @@ importers:
packages/sdks/astro:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.7-local
specifier: workspace:1.0.12-local
version: link:../web
devDependencies:
astro:
@@ -1494,7 +1497,7 @@ importers:
specifier: workspace:*
version: link:../../common
'@openpanel/sdk':
specifier: workspace:1.0.4-local
specifier: workspace:1.0.8-local
version: link:../sdk
express:
specifier: ^4.17.0 || ^5.0.0
@@ -1519,7 +1522,7 @@ importers:
packages/sdks/nextjs:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.7-local
specifier: workspace:1.0.12-local
version: link:../web
next:
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
@@ -1547,7 +1550,7 @@ importers:
packages/sdks/nuxt:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.7-local
specifier: workspace:1.0.12-local
version: link:../web
h3:
specifier: ^1.0.0
@@ -1584,7 +1587,7 @@ importers:
packages/sdks/react-native:
dependencies:
'@openpanel/sdk':
specifier: workspace:1.0.4-local
specifier: workspace:1.0.8-local
version: link:../sdk
expo-application:
specifier: 5 - 7
@@ -1630,7 +1633,7 @@ importers:
packages/sdks/web:
dependencies:
'@openpanel/sdk':
specifier: workspace:1.0.4-local
specifier: workspace:1.0.8-local
version: link:../sdk
'@rrweb/types':
specifier: 2.0.0-alpha.20
@@ -5873,14 +5876,14 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@openpanel/sdk@1.0.0':
resolution: {integrity: sha512-FNmmfjdXoC/VHEjA+WkrQ4lyM5lxEmV7xDd57uj4E+lIS0sU3DLG2mV/dpS8AscnZbUvuMn3kPhiLCqYzuv/gg==}
'@openpanel/sdk@1.0.2':
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
'@openpanel/web@1.0.1':
resolution: {integrity: sha512-cVZ7Kr9SicczJ/RDIfEtZs8+1iGDzwkabVA/j3NqSl8VSucsC8m1+LVbjmCDzCJNnK4yVn6tEcc9PJRi2rtllw==}
'@openpanel/sdk@1.0.8':
resolution: {integrity: sha512-mr7HOZ/vqrJaATDFxcv3yyLjXcUXgsfboa0o0GlhiAYUh2B1Q0kgsm5qkfbtZhTqYP4BmNCWRkfRlpFp4pfpPQ==}
'@openpanel/web@1.0.12':
resolution: {integrity: sha512-39oL19HYrw4qAzlxbtFP/rfLOaciWJXCxPwL6bk+u4SUcrrOrwmjSg0CwQYyrd2p3wp1QnsCiyv2n0EtEpjQMA==}
'@openpanel/web@1.0.5':
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
@@ -25010,13 +25013,15 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@openpanel/sdk@1.0.0': {}
'@openpanel/sdk@1.0.2': {}
'@openpanel/web@1.0.1':
'@openpanel/sdk@1.0.8': {}
'@openpanel/web@1.0.12':
dependencies:
'@openpanel/sdk': 1.0.0
'@openpanel/sdk': 1.0.8
'@rrweb/types': 2.0.0-alpha.20
rrweb: 2.0.0-alpha.20
'@openpanel/web@1.0.5':
dependencies: