feat: session replay

* wip

* wip

* wip

* wip

* final fixes

* comments

* fix
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-26 14:09:53 +01:00
committed by GitHub
parent 38d9b65ec8
commit aa81bbfe77
67 changed files with 3059 additions and 556 deletions

View File

@@ -1,19 +1,21 @@
import { getSafeJson } from '@openpanel/json';
import { cacheable } from '@openpanel/redis';
import type { IChartEventFilter } from '@openpanel/validation';
import sqlstring from 'sqlstring';
import {
TABLE_NAMES,
ch,
chQuery,
convertClickhouseDateToJs,
formatClickhouseDate,
TABLE_NAMES,
} from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
import { getOrganizationByProjectIdCached } from './organization.service';
import { type IServiceProfile, getProfilesCached } from './profile.service';
import { getProfilesCached, type IServiceProfile } from './profile.service';
export type IClickhouseSession = {
export interface IClickhouseSession {
id: string;
profile_id: string;
event_count: number;
@@ -52,7 +54,9 @@ export type IClickhouseSession = {
revenue: number;
sign: 1 | 0;
version: number;
};
// Dynamically added
has_replay?: boolean;
}
export interface IServiceSession {
id: string;
@@ -91,6 +95,7 @@ export interface IServiceSession {
utmTerm: string;
revenue: number;
profile?: IServiceProfile;
hasReplay?: boolean;
}
export interface GetSessionListOptions {
@@ -114,8 +119,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,
@@ -142,19 +147,18 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
utmTerm: session.utm_term,
revenue: session.revenue,
profile: undefined,
hasReplay: session.has_replay,
};
}
type Direction = 'initial' | 'next' | 'prev';
type PageInfo = {
interface PageInfo {
next?: Cursor; // use last row
};
}
type Cursor = {
interface Cursor {
createdAt: string; // ISO 8601 with ms
id: string;
};
}
export async function getSessionList({
cursor,
@@ -176,8 +180,9 @@ export async function getSessionList({
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
}
if (profileId)
if (profileId) {
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
}
if (search) {
const s = sqlstring.escape(`%${search}%`);
sb.where.search = `(entry_path ILIKE ${s} OR exit_path ILIKE ${s} OR referrer ILIKE ${s} OR referrer_name ILIKE ${s})`;
@@ -191,13 +196,11 @@ export async function getSessionList({
const dateIntervalInDays =
organization?.subscriptionPeriodEventsLimit &&
organization?.subscriptionPeriodEventsLimit > 1_000_000
? 1
? 2
: 360;
if (cursor) {
const cAt = sqlstring.escape(cursor.createdAt);
// TODO: remove id from cursor
const cId = sqlstring.escape(cursor.id);
sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
sb.orderBy.created_at = 'created_at DESC';
@@ -235,10 +238,14 @@ export async function getSessionList({
sb.select[column] = column;
});
sb.select.has_replay = `toBool(src.session_id != '') as hasReplay`;
sb.joins.has_replay = `LEFT JOIN (SELECT DISTINCT session_id FROM ${TABLE_NAMES.session_replay_chunks} WHERE project_id = ${sqlstring.escape(projectId)} AND started_at > now() - INTERVAL ${dateIntervalInDays} DAY) AS src ON src.session_id = id`;
const sql = getSql();
const data = await chQuery<
IClickhouseSession & {
latestCreatedAt: string;
hasReplay: boolean;
}
>(sql);
@@ -321,23 +328,79 @@ export async function getSessionsCount({
export const getSessionsCountCached = cacheable(getSessionsCount, 60 * 10);
export interface ISessionReplayChunkMeta {
chunk_index: number;
started_at: string;
ended_at: string;
events_count: number;
is_full_snapshot: boolean;
}
const REPLAY_CHUNKS_PAGE_SIZE = 40;
export async function getSessionReplayChunksFrom(
sessionId: string,
projectId: string,
fromIndex: number
) {
const rows = await chQuery<{ chunk_index: number; payload: string }>(
`SELECT chunk_index, payload
FROM ${TABLE_NAMES.session_replay_chunks}
WHERE session_id = ${sqlstring.escape(sessionId)}
AND project_id = ${sqlstring.escape(projectId)}
ORDER BY started_at, ended_at, chunk_index
LIMIT ${REPLAY_CHUNKS_PAGE_SIZE + 1}
OFFSET ${fromIndex}`
);
return {
data: rows
.slice(0, REPLAY_CHUNKS_PAGE_SIZE)
.map((row, index) => {
const events = getSafeJson<
{ type: number; data: unknown; timestamp: number }[]
>(row.payload);
if (!events) {
return null;
}
return { chunkIndex: index + fromIndex, events };
})
.filter(Boolean),
hasMore: rows.length > REPLAY_CHUNKS_PAGE_SIZE,
};
}
class SessionService {
constructor(private client: typeof ch) {}
async byId(sessionId: string, projectId: string) {
const result = await clix(this.client)
.select<IClickhouseSession>(['*'])
.from(TABLE_NAMES.sessions)
.where('id', '=', sessionId)
.where('project_id', '=', projectId)
.where('sign', '=', 1)
.execute();
const [sessionRows, hasReplayRows] = await Promise.all([
clix(this.client)
.select<IClickhouseSession>(['*'])
.from(TABLE_NAMES.sessions, true)
.where('id', '=', sessionId)
.where('project_id', '=', projectId)
.where('sign', '=', 1)
.execute(),
chQuery<{ n: number }>(
`SELECT 1 AS n
FROM ${TABLE_NAMES.session_replay_chunks}
WHERE session_id = ${sqlstring.escape(sessionId)}
AND project_id = ${sqlstring.escape(projectId)}
LIMIT 1`
),
]);
if (!result[0]) {
if (!sessionRows[0]) {
throw new Error('Session not found');
}
return transformSession(result[0]);
const session = transformSession(sessionRows[0]);
return {
...session,
hasReplay: hasReplayRows.length > 0,
};
}
}