wip
This commit is contained in:
@@ -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
|
||||
|
||||
28
.claude/notnow-settings.local.json
Normal file
28
.claude/notnow-settings.local.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
77
apps/public/public/op1-replay.js
Normal file
77
apps/public/public/op1-replay.js
Normal file
File diff suppressed because one or more lines are too long
337
apps/public/public/op1.bak.js
Normal file
337
apps/public/public/op1.bak.js
Normal 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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function Component() {
|
||||
label: 'Overview',
|
||||
},
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// }
|
||||
});
|
||||
|
||||
@@ -63,6 +63,11 @@ export async function bootCron() {
|
||||
type: 'flushProfileBackfill',
|
||||
pattern: 1000 * 30,
|
||||
},
|
||||
{
|
||||
name: 'flush',
|
||||
type: 'flushReplay',
|
||||
pattern: 1000 * 10,
|
||||
},
|
||||
{
|
||||
name: 'insightsDaily',
|
||||
type: 'insightsDaily',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
92
packages/db/src/buffers/replay-buffer.ts
Normal file
92
packages/db/src/buffers/replay-buffer.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')`.
|
||||
|
||||
@@ -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
39
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user