wip
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# CLAUDE.md
|
# 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.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## 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 { generateId, slug } from '@openpanel/common';
|
||||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||||
import {
|
import {
|
||||||
TABLE_NAMES,
|
|
||||||
ch,
|
|
||||||
getProfileById,
|
getProfileById,
|
||||||
getSalts,
|
getSalts,
|
||||||
sessionBuffer,
|
replayBuffer,
|
||||||
upsertProfile,
|
upsertProfile,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
@@ -328,12 +326,7 @@ async function handleReplay(
|
|||||||
is_full_snapshot: payload.is_full_snapshot,
|
is_full_snapshot: payload.is_full_snapshot,
|
||||||
payload: payload.payload,
|
payload: payload.payload,
|
||||||
};
|
};
|
||||||
await ch.insert({
|
await replayBuffer.add(row);
|
||||||
table: TABLE_NAMES.session_replay_chunks,
|
|
||||||
values: [row],
|
|
||||||
format: 'JSONEachRow',
|
|
||||||
});
|
|
||||||
await sessionBuffer.markHasReplay(row.session_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handler(
|
export async function handler(
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
|
||||||
import type {
|
import type {
|
||||||
DeprecatedPostEventPayload,
|
DeprecatedPostEventPayload,
|
||||||
ITrackHandlerPayload,
|
ITrackHandlerPayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||||
|
|
||||||
export async function duplicateHook(
|
export async function duplicateHook(
|
||||||
req: FastifyRequest<{
|
req: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const ip = req.clientIp;
|
const ip = req.clientIp;
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
const clientId = req.headers['openpanel-client-id'];
|
const clientId = req.headers['openpanel-client-id'];
|
||||||
const shouldCheck = ip && origin && clientId;
|
const shouldCheck = ip && origin && clientId && req.body.type !== 'replay';
|
||||||
|
|
||||||
const isDuplicate = shouldCheck
|
const isDuplicate = shouldCheck
|
||||||
? await isDuplicatedEvent({
|
? await isDuplicatedEvent({
|
||||||
@@ -25,6 +25,7 @@ export async function duplicateHook(
|
|||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
console.log('Duplicate event', isDuplicate);
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
return reply.status(200).send('Duplicate event');
|
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 type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { path, pick } from 'ramda';
|
import { path, pick } from 'ramda';
|
||||||
|
|
||||||
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
|
|||||||
const ignoreMethods = ['OPTIONS'];
|
const ignoreMethods = ['OPTIONS'];
|
||||||
|
|
||||||
const getTrpcInput = (
|
const getTrpcInput = (
|
||||||
request: FastifyRequest,
|
request: FastifyRequest
|
||||||
): Record<string, unknown> | undefined => {
|
): Record<string, unknown> | undefined => {
|
||||||
const input = path<any>(['query', 'input'], request);
|
const input = path<any>(['query', 'input'], request);
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +17,7 @@ const getTrpcInput = (
|
|||||||
|
|
||||||
export async function requestLoggingHook(
|
export async function requestLoggingHook(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
if (ignoreMethods.includes(request.method)) {
|
if (ignoreMethods.includes(request.method)) {
|
||||||
return;
|
return;
|
||||||
@@ -40,9 +39,9 @@ export async function requestLoggingHook(
|
|||||||
elapsed: reply.elapsedTime,
|
elapsed: reply.elapsedTime,
|
||||||
headers: pick(
|
headers: pick(
|
||||||
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
['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 compress from '@fastify/compress';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||||
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
import {
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
decodeSessionToken,
|
||||||
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
EMPTY_SESSION,
|
||||||
import Fastify from 'fastify';
|
type SessionValidationResult,
|
||||||
import metricsPlugin from 'fastify-metrics';
|
validateSessionToken,
|
||||||
|
} from '@openpanel/auth';
|
||||||
import { generateId } from '@openpanel/common';
|
import { generateId } from '@openpanel/common';
|
||||||
import {
|
import {
|
||||||
type IServiceClientWithProject,
|
type IServiceClientWithProject,
|
||||||
@@ -17,13 +17,11 @@ import {
|
|||||||
import { getRedisPub } from '@openpanel/redis';
|
import { getRedisPub } from '@openpanel/redis';
|
||||||
import type { AppRouter } from '@openpanel/trpc';
|
import type { AppRouter } from '@openpanel/trpc';
|
||||||
import { appRouter, createContext } from '@openpanel/trpc';
|
import { appRouter, createContext } from '@openpanel/trpc';
|
||||||
|
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||||
import {
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
EMPTY_SESSION,
|
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||||
type SessionValidationResult,
|
import Fastify from 'fastify';
|
||||||
decodeSessionToken,
|
import metricsPlugin from 'fastify-metrics';
|
||||||
validateSessionToken,
|
|
||||||
} from '@openpanel/auth';
|
|
||||||
import sourceMapSupport from 'source-map-support';
|
import sourceMapSupport from 'source-map-support';
|
||||||
import {
|
import {
|
||||||
healthcheck,
|
healthcheck,
|
||||||
@@ -72,7 +70,7 @@ const startServer = async () => {
|
|||||||
try {
|
try {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
maxParamLength: 15_000,
|
maxParamLength: 15_000,
|
||||||
bodyLimit: 1048576 * 500, // 500MB
|
bodyLimit: 1_048_576 * 500, // 500MB
|
||||||
loggerInstance: logger as unknown as FastifyBaseLogger,
|
loggerInstance: logger as unknown as FastifyBaseLogger,
|
||||||
disableRequestLogging: true,
|
disableRequestLogging: true,
|
||||||
genReqId: (req) =>
|
genReqId: (req) =>
|
||||||
@@ -84,7 +82,7 @@ const startServer = async () => {
|
|||||||
fastify.register(cors, () => {
|
fastify.register(cors, () => {
|
||||||
return (
|
return (
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
callback: (error: Error | null, options: FastifyCorsOptions) => void,
|
callback: (error: Error | null, options: FastifyCorsOptions) => void
|
||||||
) => {
|
) => {
|
||||||
// TODO: set prefix on dashboard routes
|
// TODO: set prefix on dashboard routes
|
||||||
const corsPaths = [
|
const corsPaths = [
|
||||||
@@ -97,7 +95,7 @@ const startServer = async () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const isPrivatePath = corsPaths.some((path) =>
|
const isPrivatePath = corsPaths.some((path) =>
|
||||||
req.url.startsWith(path),
|
req.url.startsWith(path)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPrivatePath) {
|
if (isPrivatePath) {
|
||||||
@@ -118,6 +116,7 @@ const startServer = async () => {
|
|||||||
|
|
||||||
return callback(null, {
|
return callback(null, {
|
||||||
origin: '*',
|
origin: '*',
|
||||||
|
maxAge: 86_400 * 7, // cache preflight for 24h
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -126,6 +125,11 @@ const startServer = async () => {
|
|||||||
global: false,
|
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', requestIdHook);
|
||||||
fastify.addHook('onRequest', timestampHook);
|
fastify.addHook('onRequest', timestampHook);
|
||||||
fastify.addHook('onRequest', ipHook);
|
fastify.addHook('onRequest', ipHook);
|
||||||
@@ -149,7 +153,7 @@ const startServer = async () => {
|
|||||||
try {
|
try {
|
||||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||||
const session = await runWithAlsSession(sessionId, () =>
|
const session = await runWithAlsSession(sessionId, () =>
|
||||||
validateSessionToken(req.cookies.session),
|
validateSessionToken(req.cookies.session)
|
||||||
);
|
);
|
||||||
req.session = session;
|
req.session = session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -158,7 +162,7 @@ const startServer = async () => {
|
|||||||
} else if (process.env.DEMO_USER_ID) {
|
} else if (process.env.DEMO_USER_ID) {
|
||||||
try {
|
try {
|
||||||
const session = await runWithAlsSession('1', () =>
|
const session = await runWithAlsSession('1', () =>
|
||||||
validateSessionToken(null),
|
validateSessionToken(null)
|
||||||
);
|
);
|
||||||
req.session = session;
|
req.session = session;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -173,7 +177,7 @@ const startServer = async () => {
|
|||||||
prefix: '/trpc',
|
prefix: '/trpc',
|
||||||
trpcOptions: {
|
trpcOptions: {
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createContext,
|
createContext,
|
||||||
onError(ctx) {
|
onError(ctx) {
|
||||||
if (
|
if (
|
||||||
ctx.error.code === 'UNAUTHORIZED' &&
|
ctx.error.code === 'UNAUTHORIZED' &&
|
||||||
@@ -217,7 +221,7 @@ const startServer = async () => {
|
|||||||
reply.send({
|
reply.send({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Successfully running OpenPanel.dev API',
|
message: 'Successfully running OpenPanel.dev API',
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,7 +278,7 @@ const startServer = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to set redis notify-keyspace-events', error);
|
logger.warn('Failed to set redis notify-keyspace-events', error);
|
||||||
logger.warn(
|
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.');
|
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 { RootProvider } from 'fumadocs-ui/provider/next';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
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 './global.css';
|
||||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||||
|
|
||||||
@@ -31,22 +31,20 @@ export const metadata: Metadata = getRootMetadata();
|
|||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
|
||||||
className={cn(font.className, mono.variable)}
|
className={cn(font.className, mono.variable)}
|
||||||
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="flex flex-col min-h-screen bg-background">
|
<body className="flex min-h-screen flex-col bg-background">
|
||||||
<RootProvider>
|
<RootProvider>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
|
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
|
||||||
<OpenPanelComponent
|
<OpenPanelComponent
|
||||||
apiUrl="/api/op"
|
|
||||||
cdnUrl="/api/op/op1.js"
|
|
||||||
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
|
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
|
||||||
trackAttributes
|
trackAttributes
|
||||||
trackScreenViews
|
|
||||||
trackOutgoingLinks
|
trackOutgoingLinks
|
||||||
|
trackScreenViews
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"@openpanel/payments": "workspace:*",
|
"@openpanel/payments": "workspace:*",
|
||||||
|
"@openpanel/sdk": "^1.0.8",
|
||||||
"@openpanel/sdk-info": "workspace:^",
|
"@openpanel/sdk-info": "workspace:^",
|
||||||
"@openpanel/validation": "workspace:^",
|
"@openpanel/validation": "workspace:^",
|
||||||
"@openpanel/web": "^1.0.1",
|
"@openpanel/web": "^1.0.12",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -116,7 +117,6 @@
|
|||||||
"pushmodal": "^1.0.3",
|
"pushmodal": "^1.0.3",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"random-animal-name": "^0.1.1",
|
"random-animal-name": "^0.1.1",
|
||||||
"rrweb-player": "2.0.0-alpha.20",
|
|
||||||
"rc-virtual-list": "^3.14.5",
|
"rc-virtual-list": "^3.14.5",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
@@ -142,6 +142,7 @@
|
|||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
|
"rrweb-player": "2.0.0-alpha.20",
|
||||||
"short-unique-id": "^5.0.3",
|
"short-unique-id": "^5.0.3",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.4.0",
|
"sonner": "^1.4.0",
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
|
|||||||
className="h-full"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
|
<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 }) => (
|
{visibleEvents.map(({ event, offsetMs }) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
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
|
<ReplayEventItem
|
||||||
event={event}
|
event={event}
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { EventIcon } from '@/components/events/event-icon';
|
import { EventIcon } from '@/components/events/event-icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { IServiceEvent } from '@openpanel/db';
|
import type { IServiceEvent } from '@openpanel/db';
|
||||||
|
|
||||||
function formatOffset(ms: number): string {
|
function formatTime(date: Date | string): string {
|
||||||
const sign = ms < 0 ? '-' : '+';
|
const d = date instanceof Date ? date : new Date(date);
|
||||||
const abs = Math.abs(ms);
|
const h = d.getHours().toString().padStart(2, '0');
|
||||||
const totalSeconds = Math.floor(abs / 1000);
|
const m = d.getMinutes().toString().padStart(2, '0');
|
||||||
const m = Math.floor(totalSeconds / 60);
|
const s = d.getSeconds().toString().padStart(2, '0');
|
||||||
const s = totalSeconds % 60;
|
return `${h}:${m}:${s}`;
|
||||||
return `${sign}${m}:${s.toString().padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReplayEventItem({
|
export function ReplayEventItem({
|
||||||
event,
|
event,
|
||||||
offsetMs,
|
|
||||||
isCurrent,
|
isCurrent,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
@@ -48,7 +44,7 @@ export function ReplayEventItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
{formatOffset(offsetMs)}
|
{formatTime(event.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-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}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useLocation } from '@tanstack/react-router';
|
|||||||
|
|
||||||
export function usePageTabs(tabs: { id: string; label: string }[]) {
|
export function usePageTabs(tabs: { id: string; label: string }[]) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const tab = location.pathname.split('/').pop();
|
const segments = location.pathname.split('/').filter(Boolean);
|
||||||
|
const tab = segments[segments.length - 1];
|
||||||
|
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
return {
|
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 AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
||||||
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
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 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'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||||
|
|
||||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
||||||
@@ -557,6 +558,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
|
||||||
|
id: '/sessions',
|
||||||
|
path: '/sessions',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport.update({
|
||||||
id: '/events',
|
id: '/events',
|
||||||
@@ -633,6 +640,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -695,6 +703,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -778,6 +787,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/_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
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
@@ -851,6 +861,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/profiles/'
|
| '/$organizationId/$projectId/profiles/'
|
||||||
| '/$organizationId/$projectId/settings/'
|
| '/$organizationId/$projectId/settings/'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
|
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/'
|
| '/$organizationId/$projectId/profiles/$profileId/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -913,6 +924,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
|
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -995,6 +1007,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||||
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
@@ -1578,6 +1591,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
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': {
|
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': {
|
||||||
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||||
path: '/events'
|
path: '/events'
|
||||||
@@ -1689,6 +1709,7 @@ const AppOrganizationIdProjectIdProfilesTabsRouteWithChildren =
|
|||||||
|
|
||||||
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
|
interface AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren {
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,6 +1717,8 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsRouteChildren: AppOrganizat
|
|||||||
{
|
{
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute:
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
|
AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute,
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute:
|
||||||
|
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute,
|
||||||
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
|
AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute:
|
||||||
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',
|
label: 'Overview',
|
||||||
},
|
},
|
||||||
{ id: 'events', label: 'Events' },
|
{ id: 'events', label: 'Events' },
|
||||||
|
{ id: 'sessions', label: 'Sessions' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleTabChange = (tabId: string) => {
|
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 FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { ReplayShell } from '@/components/sessions/replay';
|
import { ReplayShell } from '@/components/sessions/replay';
|
||||||
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||||
import {
|
import { Widget, WidgetBody, WidgetHead, WidgetTitle } from '@/components/widget';
|
||||||
useEventQueryFilters,
|
|
||||||
useEventQueryNamesFilter,
|
|
||||||
} from '@/hooks/use-event-query-filters';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
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 { createProjectTitle } from '@/utils/title';
|
||||||
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { Link, createFileRoute } from '@tanstack/react-router';
|
||||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
import type { IServiceEvent, IServiceSession } from '@openpanel/db';
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/sessions_/$sessionId',
|
'/_app/$organizationId/$projectId/sessions_/$sessionId',
|
||||||
@@ -27,101 +28,353 @@ export const Route = createFileRoute(
|
|||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
context.queryClient.prefetchQuery(
|
||||||
|
context.trpc.event.events.queryOptions({
|
||||||
|
projectId: params.projectId,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
filters: [],
|
||||||
|
columnVisibility: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
head: () => {
|
head: () => ({
|
||||||
return {
|
meta: [{ title: createProjectTitle('Session') }],
|
||||||
meta: [
|
}),
|
||||||
{
|
|
||||||
title: createProjectTitle('Sessions'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
pendingComponent: FullPageLoadingState,
|
pendingComponent: FullPageLoadingState,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Component() {
|
function sessionToFakeEvent(session: IServiceSession): IServiceEvent {
|
||||||
const { projectId, sessionId } = Route.useParams();
|
return session as unknown as IServiceEvent;
|
||||||
const trpc = useTRPC();
|
}
|
||||||
|
|
||||||
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(
|
const { data: session } = useSuspenseQuery(
|
||||||
trpc.session.byId.queryOptions({
|
trpc.session.byId.queryOptions({ sessionId, projectId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: eventsData } = useSuspenseQuery(
|
||||||
|
trpc.event.events.queryOptions({
|
||||||
|
projectId,
|
||||||
sessionId,
|
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,
|
projectId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [filters] = useEventQueryFilters();
|
const fakeEvent = sessionToFakeEvent(session);
|
||||||
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,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer className="col gap-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`Session: ${session.id.slice(0, 4)}...${session.id.slice(-4)}`}
|
title={`Session: ${session.id}`}
|
||||||
>
|
>
|
||||||
<div className="row gap-4 mb-6">
|
<div className="row gap-4 mb-6">
|
||||||
{session.country && (
|
{session.country && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row gap-2 items-center">
|
||||||
<SerieIcon name={session.country} />
|
<SerieIcon name={session.country} />
|
||||||
<span>
|
<span>
|
||||||
{session.country}
|
{session.country}
|
||||||
{session.city && ` / ${session.city}`}
|
{session.city && ` / ${session.city}`}
|
||||||
</span>
|
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
{session.device && (
|
)}
|
||||||
<div className="row gap-2 items-center">
|
{session.device && (
|
||||||
<SerieIcon name={session.device} />
|
<div className="row gap-2 items-center">
|
||||||
<span className="capitalize">{session.device}</span>
|
<SerieIcon name={session.device} />
|
||||||
</div>
|
<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.os && (
|
||||||
)}
|
<div className="row gap-2 items-center">
|
||||||
{session.model && (
|
<SerieIcon name={session.os} />
|
||||||
<div className="row gap-2 items-center">
|
<span>{session.os}</span>
|
||||||
<SerieIcon name={session.model} />
|
|
||||||
<span>{session.model}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.model && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={session.model} />
|
||||||
|
<span>{session.model}</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{session.browser && (
|
{session.browser && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row gap-2 items-center">
|
||||||
<SerieIcon name={session.browser} />
|
<SerieIcon name={session.browser} />
|
||||||
<span>{session.browser}</span>
|
<span>{session.browser}</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</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>
|
</div>
|
||||||
<EventsTable query={query} />
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,14 @@ import { OpenPanel } from '@openpanel/web';
|
|||||||
|
|
||||||
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
|
const clientId = import.meta.env.VITE_OP_CLIENT_ID;
|
||||||
|
|
||||||
const createOpInstance = () => {
|
export const op = new OpenPanel({
|
||||||
if (!clientId || clientId === 'undefined') {
|
clientId,
|
||||||
return new Proxy({} as OpenPanel, {
|
disabled: clientId === 'undefined' || !clientId,
|
||||||
get: () => () => {},
|
// apiUrl: 'http://localhost:3333',
|
||||||
});
|
trackScreenViews: true,
|
||||||
}
|
trackOutgoingLinks: true,
|
||||||
|
trackAttributes: true,
|
||||||
return new OpenPanel({
|
// sessionReplay: {
|
||||||
clientId,
|
// enabled: true,
|
||||||
trackScreenViews: true,
|
// }
|
||||||
trackOutgoingLinks: true,
|
});
|
||||||
trackAttributes: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const op = createOpInstance();
|
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export async function bootCron() {
|
|||||||
type: 'flushProfileBackfill',
|
type: 'flushProfileBackfill',
|
||||||
pattern: 1000 * 30,
|
pattern: 1000 * 30,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'flush',
|
||||||
|
type: 'flushReplay',
|
||||||
|
pattern: 1000 * 10,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'insightsDaily',
|
name: 'insightsDaily',
|
||||||
type: 'insightsDaily',
|
type: 'insightsDaily',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Job } from 'bullmq';
|
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 type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
import { jobdeleteProjects } from './cron.delete-projects';
|
import { jobdeleteProjects } from './cron.delete-projects';
|
||||||
@@ -26,6 +26,9 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
|||||||
case 'flushProfileBackfill': {
|
case 'flushProfileBackfill': {
|
||||||
return await profileBackfillBuffer.tryFlush();
|
return await profileBackfillBuffer.tryFlush();
|
||||||
}
|
}
|
||||||
|
case 'flushReplay': {
|
||||||
|
return await replayBuffer.tryFlush();
|
||||||
|
}
|
||||||
case 'ping': {
|
case 'ping': {
|
||||||
return await ping();
|
return await ping();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
botBuffer,
|
botBuffer,
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
profileBuffer,
|
profileBuffer,
|
||||||
|
replayBuffer,
|
||||||
sessionBuffer,
|
sessionBuffer,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { cronQueue, eventsGroupQueues, sessionsQueue } from '@openpanel/queue';
|
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',
|
replicatedVersion: '1',
|
||||||
isClustered,
|
isClustered,
|
||||||
}),
|
}),
|
||||||
...addColumns(
|
|
||||||
TABLE_NAMES.sessions,
|
|
||||||
['`has_replay` Bool DEFAULT 0'],
|
|
||||||
isClustered,
|
|
||||||
),
|
|
||||||
modifyTTL({
|
modifyTTL({
|
||||||
tableName: TABLE_NAMES.session_replay_chunks,
|
tableName: TABLE_NAMES.session_replay_chunks,
|
||||||
isClustered,
|
isClustered,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BotBuffer as BotBufferRedis } from './bot-buffer';
|
|||||||
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
import { EventBuffer as EventBufferRedis } from './event-buffer';
|
||||||
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
import { ProfileBackfillBuffer } from './profile-backfill-buffer';
|
||||||
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
import { ProfileBuffer as ProfileBufferRedis } from './profile-buffer';
|
||||||
|
import { ReplayBuffer } from './replay-buffer';
|
||||||
import { SessionBuffer } from './session-buffer';
|
import { SessionBuffer } from './session-buffer';
|
||||||
|
|
||||||
export const eventBuffer = new EventBufferRedis();
|
export const eventBuffer = new EventBufferRedis();
|
||||||
@@ -9,5 +10,7 @@ export const profileBuffer = new ProfileBufferRedis();
|
|||||||
export const botBuffer = new BotBufferRedis();
|
export const botBuffer = new BotBufferRedis();
|
||||||
export const sessionBuffer = new SessionBuffer();
|
export const sessionBuffer = new SessionBuffer();
|
||||||
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
export const profileBackfillBuffer = new ProfileBackfillBuffer();
|
||||||
|
export const replayBuffer = new ReplayBuffer();
|
||||||
|
|
||||||
export type { ProfileBackfillEntry } from './profile-backfill-buffer';
|
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,
|
sign: 1,
|
||||||
version: 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) {
|
async add(event: IClickhouseEvent) {
|
||||||
if (!event.session_id) {
|
if (!event.session_id) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
ch,
|
ch,
|
||||||
chQuery,
|
chQuery,
|
||||||
|
convertClickhouseDateToJs,
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
} from '../clickhouse/client';
|
} from '../clickhouse/client';
|
||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
@@ -52,7 +53,7 @@ export type IClickhouseSession = {
|
|||||||
revenue: number;
|
revenue: number;
|
||||||
sign: 1 | 0;
|
sign: 1 | 0;
|
||||||
version: number;
|
version: number;
|
||||||
has_replay?: boolean;
|
has_replay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IServiceSession {
|
export interface IServiceSession {
|
||||||
@@ -116,8 +117,8 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
entryOrigin: session.entry_origin,
|
entryOrigin: session.entry_origin,
|
||||||
exitPath: session.exit_path,
|
exitPath: session.exit_path,
|
||||||
exitOrigin: session.exit_origin,
|
exitOrigin: session.exit_origin,
|
||||||
createdAt: new Date(session.created_at),
|
createdAt: convertClickhouseDateToJs(session.created_at),
|
||||||
endedAt: new Date(session.ended_at),
|
endedAt: convertClickhouseDateToJs(session.ended_at),
|
||||||
referrer: session.referrer,
|
referrer: session.referrer,
|
||||||
referrerName: session.referrer_name,
|
referrerName: session.referrer_name,
|
||||||
referrerType: session.referrer_type,
|
referrerType: session.referrer_type,
|
||||||
@@ -143,7 +144,7 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
|
|||||||
utmContent: session.utm_content,
|
utmContent: session.utm_content,
|
||||||
utmTerm: session.utm_term,
|
utmTerm: session.utm_term,
|
||||||
revenue: session.revenue,
|
revenue: session.revenue,
|
||||||
hasReplay: session.has_replay ?? false,
|
hasReplay: session.has_replay,
|
||||||
profile: undefined,
|
profile: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -230,13 +231,14 @@ export async function getSessionList({
|
|||||||
'screen_view_count',
|
'screen_view_count',
|
||||||
'event_count',
|
'event_count',
|
||||||
'revenue',
|
'revenue',
|
||||||
'has_replay',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
columns.forEach((column) => {
|
columns.forEach((column) => {
|
||||||
sb.select[column] = 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 sql = getSql();
|
||||||
const data = await chQuery<
|
const data = await chQuery<
|
||||||
IClickhouseSession & {
|
IClickhouseSession & {
|
||||||
|
|||||||
@@ -125,12 +125,17 @@ export type CronQueuePayloadFlushProfileBackfill = {
|
|||||||
type: 'flushProfileBackfill';
|
type: 'flushProfileBackfill';
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
export type CronQueuePayloadFlushReplay = {
|
||||||
|
type: 'flushReplay';
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
export type CronQueuePayload =
|
export type CronQueuePayload =
|
||||||
| CronQueuePayloadSalt
|
| CronQueuePayloadSalt
|
||||||
| CronQueuePayloadFlushEvents
|
| CronQueuePayloadFlushEvents
|
||||||
| CronQueuePayloadFlushSessions
|
| CronQueuePayloadFlushSessions
|
||||||
| CronQueuePayloadFlushProfiles
|
| CronQueuePayloadFlushProfiles
|
||||||
| CronQueuePayloadFlushProfileBackfill
|
| CronQueuePayloadFlushProfileBackfill
|
||||||
|
| CronQueuePayloadFlushReplay
|
||||||
| CronQueuePayloadPing
|
| CronQueuePayloadPing
|
||||||
| CronQueuePayloadProject
|
| CronQueuePayloadProject
|
||||||
| CronQueuePayloadInsightsDaily
|
| CronQueuePayloadInsightsDaily
|
||||||
|
|||||||
@@ -95,10 +95,11 @@ export class OpenPanel {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable keepalive for replay since it has a hard body limit and breaks the request
|
||||||
const result = await this.api.fetch<
|
const result = await this.api.fetch<
|
||||||
TrackHandlerPayload,
|
TrackHandlerPayload,
|
||||||
{ deviceId: string; sessionId: string }
|
{ deviceId: string; sessionId: string }
|
||||||
>('/track', payload);
|
>('/track', payload, { keepalive: payload.type !== 'replay' });
|
||||||
this.deviceId = result?.deviceId;
|
this.deviceId = result?.deviceId;
|
||||||
const hadSession = !!this.sessionId;
|
const hadSession = !!this.sessionId;
|
||||||
this.sessionId = result?.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
|
// string literal only in the IIFE build, so this branch is
|
||||||
// dead-code-eliminated in the library build.
|
// dead-code-eliminated in the library build.
|
||||||
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
|
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
|
||||||
// IIFE / script-tag context — load from CDN (or user override)
|
const scriptEl = document.currentScript as HTMLScriptElement | null;
|
||||||
const url =
|
const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
|
||||||
this.options.sessionReplay?.scriptUrl ?? __OPENPANEL_REPLAY_URL__;
|
|
||||||
|
|
||||||
// Already loaded (e.g. user included the script manually)
|
// Already loaded (e.g. user included the script manually)
|
||||||
if ((window as any).__openpanel_replay) {
|
if ((window as any).__openpanel_replay) {
|
||||||
|
|||||||
@@ -42,15 +42,29 @@ export function startReplayRecorder(
|
|||||||
function flush(isFullSnapshot: boolean): void {
|
function flush(isFullSnapshot: boolean): void {
|
||||||
if (buffer.length === 0) return;
|
if (buffer.length === 0) return;
|
||||||
|
|
||||||
const startedAt = buffer[0]!.timestamp;
|
|
||||||
const endedAt = buffer[buffer.length - 1]!.timestamp;
|
|
||||||
const payloadJson = JSON.stringify(buffer);
|
const payloadJson = JSON.stringify(buffer);
|
||||||
|
|
||||||
if (payloadJson.length > maxPayloadBytes) {
|
if (payloadJson.length > maxPayloadBytes) {
|
||||||
// If over size limit, split by taking only up to maxPayloadBytes (simplified: flush as-is and log or truncate)
|
if (buffer.length > 1) {
|
||||||
// For MVP we still send; server will reject if over 1MB
|
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({
|
sendChunk({
|
||||||
chunk_index: chunkIndex,
|
chunk_index: chunkIndex,
|
||||||
events_count: buffer.length,
|
events_count: buffer.length,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export default defineConfig([
|
|||||||
// __OPENPANEL_REPLAY_URL__ is injected at build time so the IIFE
|
// __OPENPANEL_REPLAY_URL__ is injected at build time so the IIFE
|
||||||
// knows to load the replay module from the CDN instead of a
|
// knows to load the replay module from the CDN instead of a
|
||||||
// relative import (which doesn't work in a standalone script).
|
// 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' },
|
entry: { 'src/tracker': 'src/tracker.ts' },
|
||||||
format: ['iife'],
|
format: ['iife'],
|
||||||
@@ -25,9 +27,30 @@ export default defineConfig([
|
|||||||
minify: true,
|
minify: true,
|
||||||
define: {
|
define: {
|
||||||
__OPENPANEL_REPLAY_URL__: JSON.stringify(
|
__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).
|
// Replay module — built as both ESM (npm) and IIFE (CDN).
|
||||||
// ESM → consumed by the host-app's bundler via `import('./replay')`.
|
// 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({
|
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),
|
events_count: z.number().int().min(1),
|
||||||
is_full_snapshot: z.boolean(),
|
is_full_snapshot: z.boolean(),
|
||||||
started_at: z.string(),
|
started_at: z.string(),
|
||||||
ended_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', [
|
export const zTrackHandlerPayload = z.discriminatedUnion('type', [
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -465,6 +465,9 @@ importers:
|
|||||||
'@openpanel/payments':
|
'@openpanel/payments':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/payments
|
version: link:../../packages/payments
|
||||||
|
'@openpanel/sdk':
|
||||||
|
specifier: ^1.0.8
|
||||||
|
version: 1.0.8
|
||||||
'@openpanel/sdk-info':
|
'@openpanel/sdk-info':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/sdks/_info
|
version: link:../../packages/sdks/_info
|
||||||
@@ -472,8 +475,8 @@ importers:
|
|||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/validation
|
version: link:../../packages/validation
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: ^1.0.1
|
specifier: ^1.0.12
|
||||||
version: 1.0.1
|
version: 1.0.12
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.12
|
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)
|
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:
|
packages/sdks/astro:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: workspace:1.0.7-local
|
specifier: workspace:1.0.12-local
|
||||||
version: link:../web
|
version: link:../web
|
||||||
devDependencies:
|
devDependencies:
|
||||||
astro:
|
astro:
|
||||||
@@ -1494,7 +1497,7 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../common
|
version: link:../../common
|
||||||
'@openpanel/sdk':
|
'@openpanel/sdk':
|
||||||
specifier: workspace:1.0.4-local
|
specifier: workspace:1.0.8-local
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
express:
|
express:
|
||||||
specifier: ^4.17.0 || ^5.0.0
|
specifier: ^4.17.0 || ^5.0.0
|
||||||
@@ -1519,7 +1522,7 @@ importers:
|
|||||||
packages/sdks/nextjs:
|
packages/sdks/nextjs:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: workspace:1.0.7-local
|
specifier: workspace:1.0.12-local
|
||||||
version: link:../web
|
version: link:../web
|
||||||
next:
|
next:
|
||||||
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||||
@@ -1547,7 +1550,7 @@ importers:
|
|||||||
packages/sdks/nuxt:
|
packages/sdks/nuxt:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/web':
|
'@openpanel/web':
|
||||||
specifier: workspace:1.0.7-local
|
specifier: workspace:1.0.12-local
|
||||||
version: link:../web
|
version: link:../web
|
||||||
h3:
|
h3:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
@@ -1584,7 +1587,7 @@ importers:
|
|||||||
packages/sdks/react-native:
|
packages/sdks/react-native:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/sdk':
|
'@openpanel/sdk':
|
||||||
specifier: workspace:1.0.4-local
|
specifier: workspace:1.0.8-local
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
expo-application:
|
expo-application:
|
||||||
specifier: 5 - 7
|
specifier: 5 - 7
|
||||||
@@ -1630,7 +1633,7 @@ importers:
|
|||||||
packages/sdks/web:
|
packages/sdks/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@openpanel/sdk':
|
'@openpanel/sdk':
|
||||||
specifier: workspace:1.0.4-local
|
specifier: workspace:1.0.8-local
|
||||||
version: link:../sdk
|
version: link:../sdk
|
||||||
'@rrweb/types':
|
'@rrweb/types':
|
||||||
specifier: 2.0.0-alpha.20
|
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: ^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
|
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':
|
'@openpanel/sdk@1.0.2':
|
||||||
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
|
resolution: {integrity: sha512-WvVWCBcJvJhM5MYKO5Hxjo4G/E0tnK5XK2UC+hKDHtoF+iKvtUWa5bz18pFAPQprq0u/Ev2YqirPsrMQJy5g2g==}
|
||||||
|
|
||||||
'@openpanel/web@1.0.1':
|
'@openpanel/sdk@1.0.8':
|
||||||
resolution: {integrity: sha512-cVZ7Kr9SicczJ/RDIfEtZs8+1iGDzwkabVA/j3NqSl8VSucsC8m1+LVbjmCDzCJNnK4yVn6tEcc9PJRi2rtllw==}
|
resolution: {integrity: sha512-mr7HOZ/vqrJaATDFxcv3yyLjXcUXgsfboa0o0GlhiAYUh2B1Q0kgsm5qkfbtZhTqYP4BmNCWRkfRlpFp4pfpPQ==}
|
||||||
|
|
||||||
|
'@openpanel/web@1.0.12':
|
||||||
|
resolution: {integrity: sha512-39oL19HYrw4qAzlxbtFP/rfLOaciWJXCxPwL6bk+u4SUcrrOrwmjSg0CwQYyrd2p3wp1QnsCiyv2n0EtEpjQMA==}
|
||||||
|
|
||||||
'@openpanel/web@1.0.5':
|
'@openpanel/web@1.0.5':
|
||||||
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
|
resolution: {integrity: sha512-n/A9fKiHWcDTH2N6N8MM214ET7aoNJjgpLux0GRW+CD0KDEwI8UosQvvz3UOGHZ3jWqMMsUNdU2B7eYk2W87mg==}
|
||||||
@@ -25010,13 +25013,15 @@ snapshots:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
'@openpanel/sdk@1.0.0': {}
|
|
||||||
|
|
||||||
'@openpanel/sdk@1.0.2': {}
|
'@openpanel/sdk@1.0.2': {}
|
||||||
|
|
||||||
'@openpanel/web@1.0.1':
|
'@openpanel/sdk@1.0.8': {}
|
||||||
|
|
||||||
|
'@openpanel/web@1.0.12':
|
||||||
dependencies:
|
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':
|
'@openpanel/web@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user