diff --git a/admin/src/billing/pricing.ts b/admin/src/billing/pricing.ts index 091b818a2..a9d2acf18 100644 --- a/admin/src/billing/pricing.ts +++ b/admin/src/billing/pricing.ts @@ -44,15 +44,15 @@ export const planEntitlements = { websites: 50, teamMembers: 10, organisations: 3, - apiRateLimitPerHour: 600, + apiRateLimitPerHour: 300, sessionReplaysIncluded: 0, }, [PlanType.plus]: { websites: 100, teamMembers: 25, organisations: 10, - apiRateLimitPerHour: 5000, - sessionReplaysIncluded: 50000, + apiRateLimitPerHour: 6000, + sessionReplaysIncluded: 'byEventTier', }, [PlanType.enterprise]: { websites: 'custom', @@ -63,6 +63,21 @@ export const planEntitlements = { }, } as const +const sessionReplayQuotas: Partial> = { + [PlanCode['100k']]: 5000, + [PlanCode['200k']]: 10000, + [PlanCode['500k']]: 25000, + [PlanCode['1m']]: 50000, + [PlanCode['2m']]: 100000, + [PlanCode['5m']]: 250000, + [PlanCode['10m']]: 500000, + [PlanCode['15m']]: 750000, + [PlanCode['20m']]: 1000000, + [PlanCode['30m']]: 1500000, + [PlanCode['40m']]: 2000000, + [PlanCode['50m']]: 2500000, +} + const numberValue = (source: Record | null, key: string) => source && typeof source[key] === 'number' ? (source[key] as number) : null @@ -88,6 +103,11 @@ export const getEffectiveLimits = (user: User) => { user.entitlementOverrides, 'sessionReplaysIncluded', ) + const replayQuota = + replayOverride ?? + (entitlements.sessionReplaysIncluded === 'byEventTier' + ? sessionReplayQuotas[user.planCode] || 0 + : entitlements.sessionReplaysIncluded) const websites = websiteOverride ?? (typeof entitlements.websites === 'number' @@ -105,7 +125,7 @@ export const getEffectiveLimits = (user: User) => { websitesIncluded: typeof websites === 'number' ? websites + addonWebsites : websites, purchasedWebsiteAddons: addonWebsites, - replayQuota: replayOverride ?? entitlements.sessionReplaysIncluded, + replayQuota, apiRateLimitPerHour, } } diff --git a/backend/.env.example b/backend/.env.example index a665fd4f2..114c5f2ba 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -109,6 +109,23 @@ EMAIL_ACTION_ENCRYPTION_KEY= # Swetrix Enterprise (Cloud) SWETRIX_LICENSE_KEY= +# Hetzner S3 storage defaults. Session Replay uses these when the +# SESSION_REPLAY_S3_* variables below are not set. +# HETZNER_S3_ENDPOINT is the location endpoint, for example https://fsn1.your-objectstorage.com. +# Do not include the bucket path in HETZNER_S3_ENDPOINT. +HETZNER_S3_ENDPOINT= +HETZNER_S3_BUCKET= +HETZNER_S3_ACCESS_KEY_ID= +HETZNER_S3_SECRET_ACCESS_KEY= + +# Hetzner S3 storage for Session Replays. +# SESSION_REPLAY_S3_ENDPOINT is the location endpoint, for example https://fsn1.your-objectstorage.com. +# Do not include the bucket path in SESSION_REPLAY_S3_ENDPOINT. +SESSION_REPLAY_S3_ENDPOINT= +SESSION_REPLAY_S3_BUCKET= +SESSION_REPLAY_S3_ACCESS_KEY_ID= +SESSION_REPLAY_S3_SECRET_ACCESS_KEY= + # OpenRouter API key for AI features OPENROUTER_API_KEY= diff --git a/backend/Dockerfile b/backend/Dockerfile index 80f94828c..bc49a390b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,9 @@ #Stage 1: Build -FROM node:lts-alpine AS build +FROM mcr.microsoft.com/playwright:v1.60.0-noble AS build WORKDIR /build +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 COPY package*.json ./ +COPY scripts/patch-rrvideo.js ./scripts/patch-rrvideo.js RUN npm ci --force COPY . . RUN npm run deploy:community @@ -9,7 +11,7 @@ RUN node ./meta/dbip-free-sync.js RUN npm prune --omit=dev --force #Stage 2: Runtime -FROM node:lts-alpine AS run +FROM mcr.microsoft.com/playwright:v1.60.0-noble AS run LABEL maintainer="swetrix.com " ENV TZ=UTC \ @@ -32,10 +34,11 @@ ENV OIDC_CLIENT_ID='' \ OIDC_DISCOVERY_URL='' \ OIDC_CLIENT_SECRET='' -# Install and configure timezone -RUN apk add --no-cache tzdata && \ +# Install video tooling and configure timezone +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg tzdata && \ cp /usr/share/zoneinfo/$TZ /etc/localtime && \ - apk del tzdata + rm -rf /var/lib/apt/lists/* # Use non-root numeric user USER 999 diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 3280fc9ec..0e7275812 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -12,12 +12,14 @@ import { Controller, Body, Query, + Param, UseGuards, UsePipes, ValidationPipe, Get, Post, Patch, + Delete, Headers, BadRequestException, InternalServerErrorException, @@ -28,7 +30,9 @@ import { Header, HttpException, HttpStatus, + StreamableFile, } from '@nestjs/common' +import type { Response as ExpressResponse } from 'express' import { OptionalJwtAccessTokenGuard } from '../auth/guards' import { Auth, Public } from '../auth/decorators' @@ -71,6 +75,14 @@ import { } from './interfaces' import { GetSessionsDto } from './dto/get-sessions.dto' import { GetSessionDto } from './dto/get-session.dto' +import { + GetSessionReplayDto, + GetSessionReplaysDto, + SessionReplayExportStartDto, + SessionReplayChunkDto, + SessionReplayStartDto, +} from './dto/session-replay.dto' +import { SessionReplayExportService } from './session-replay-export.service' import { GetProfilesDto } from './dto/get-profiles.dto' import { GetProfileDto, GetProfileSessionsDto } from './dto/get-profile.dto' import { ErrorDto } from './dto/error.dto' @@ -235,6 +247,7 @@ export class AnalyticsController { private readonly logger: AppLoggerService, private readonly gscService: GSCService, private readonly experimentService: ExperimentService, + private readonly sessionReplayExportService: SessionReplayExportService, ) {} @ApiBearerAuth() @@ -1485,6 +1498,101 @@ export class AnalyticsController { return this.analyticsService.getBotStats(pid, period) } + @Post('session-replay/start') + @Public() + async startSessionReplay( + @Body() replayDTO: SessionReplayStartDto, + @Headers() headers, + @Ip() reqIP, + ) { + const { 'user-agent': userAgent, origin } = headers + const ip = getIPFromHeaders(headers) || reqIP || '' + + await checkRateLimit(ip, 'session-replay-start', 120, 60) + await checkRateLimit(replayDTO.pid, 'session-replay-start', 2000, 60) + + const botResult = await this.analyticsService.checkBot( + replayDTO.pid, + userAgent, + headers, + ip, + headers.referer || headers.referrer, + replayDTO.pg, + 'session_replay', + ) + + if (botResult.isBot) { + return BOT_RESPONSE + } + + const project = await this.analyticsService.validate(replayDTO, origin, ip) + const { country } = getIPDetails(ip, replayDTO.tz) + this.analyticsService.checkCountryBlacklist(project, country) + + this.logger.log( + `pid: ${replayDTO.pid}, replayId: ${replayDTO.replayId}`, + 'POST /analytics/session-replay/start', + ) + + return this.analyticsService.startSessionReplay( + project, + replayDTO.pid, + replayDTO.replayId, + replayDTO.privacy, + userAgent, + ip, + replayDTO.profileId, + ) + } + + @Post('session-replay/chunk') + @Public() + async uploadSessionReplayChunk( + @Body() replayDTO: SessionReplayChunkDto, + @Headers() headers, + @Ip() reqIP, + ) { + const { 'user-agent': userAgent, origin } = headers + const ip = getIPFromHeaders(headers) || reqIP || '' + + await checkRateLimit(ip, 'session-replay-chunk', 600, 60) + await checkRateLimit(replayDTO.pid, 'session-replay-chunk', 5000, 60) + + const botResult = await this.analyticsService.checkBot( + replayDTO.pid, + userAgent, + headers, + ip, + headers.referer || headers.referrer, + replayDTO.pg, + 'session_replay', + ) + + if (botResult.isBot) { + return BOT_RESPONSE + } + + const project = await this.analyticsService.validate(replayDTO, origin, ip) + const { country } = getIPDetails(ip, replayDTO.tz) + this.analyticsService.checkCountryBlacklist(project, country) + + this.logger.log( + `pid: ${replayDTO.pid}, replayId: ${replayDTO.replayId}, chunkIndex: ${replayDTO.chunkIndex}`, + 'POST /analytics/session-replay/chunk', + ) + + return this.analyticsService.storeSessionReplayChunk( + project, + replayDTO.pid, + replayDTO.replayId, + replayDTO.privacy, + replayDTO.chunkIndex, + replayDTO.events, + userAgent, + ip, + ) + } + @Post('error') @Public() async logError(@Body() errorDTO: ErrorDto, @Headers() headers, @Ip() reqIP) { @@ -2274,6 +2382,86 @@ export class AnalyticsController { return { sessions, appliedFilters, take, skip } } + @Get('session-replays') + @Auth(true, true) + async getSessionReplays( + @Query() data: GetSessionReplaysDto, + @CurrentUserId() uid: string, + @Headers() headers: { 'x-password'?: string }, + ) { + const { pid, period, from, to, filters, timezone = DEFAULT_TIMEZONE } = data + + await this.analyticsService.checkProjectAccess( + pid, + uid, + headers['x-password'], + ) + + await this.analyticsService.checkBillingAccess(pid) + + const take = this.analyticsService.getSafeNumber(data.take, 30) + const skip = this.analyticsService.getSafeNumber(data.skip, 0) + + if (take > 150) { + throw new BadRequestException( + 'The maximum number of session replays to return is 150', + ) + } + + this.logger.log( + `pid: ${pid}, period: ${period}, take: ${take}, skip: ${skip}`, + 'GET /analytics/session-replays', + ) + + const [filtersQuery, filtersParams, appliedFilters, customEVFilterApplied] = + this.analyticsService.getFiltersQuery(filters, DataType.ANALYTICS) + + let timeBucket + let diff + + if (period === 'all') { + const res = await this.analyticsService.calculateTimeBucketForAllTime( + pid, + ['pageview', 'custom_event', 'error'] as const, + ) + + timeBucket = res.timeBucket[0] + diff = res.diff + } else { + timeBucket = getLowestPossibleTimeBucket(period, from, to) + } + + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + diff, + ) + + const paramsData = { + params: { + pid, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + ...filtersParams, + }, + } + + const replays = await this.analyticsService.getSessionReplaysList( + filtersQuery, + paramsData, + safeTimezone, + take, + skip, + customEVFilterApplied, + ) + + return { replays, appliedFilters, take, skip } + } + @Get('errors') @Auth(true, true) async getErrors( @@ -2635,6 +2823,123 @@ export class AnalyticsController { return result } + @Get('session-replay') + @Auth(true, true) + async getSessionReplay( + @Query() data: GetSessionReplayDto, + @CurrentUserId() uid: string, + @Headers() headers: { 'x-password'?: string }, + ) { + const { pid, psid, replayId } = data + + await this.analyticsService.checkProjectAccess( + pid, + uid, + headers['x-password'], + ) + + await this.analyticsService.checkBillingAccess(pid) + + this.logger.log( + `pid: ${pid}, psid: ${psid}, replayId: ${replayId || 'latest'}`, + 'GET /analytics/session-replay', + ) + + return this.analyticsService.getSessionReplay(pid, psid, replayId) + } + + @Delete('session-replay') + @Auth(true, true) + async deleteSessionReplay( + @Query() data: GetSessionReplayDto, + @CurrentUserId() uid: string, + ) { + const { pid, psid, replayId } = data + + await this.analyticsService.checkManageAccess(pid, uid) + await this.analyticsService.checkBillingAccess(pid) + + this.logger.log( + `pid: ${pid}, psid: ${psid}, replayId: ${replayId || 'latest'}`, + 'DELETE /analytics/session-replay', + ) + + return this.analyticsService.deleteSessionReplay(pid, psid, replayId) + } + + @Post('session-replay/export') + @Auth(true, true) + async startSessionReplayExport( + @Body() data: SessionReplayExportStartDto, + @CurrentUserId() uid: string | null, + @Headers() headers: { 'x-password'?: string; 'user-agent'?: string }, + @Ip() requestIp: string, + ) { + const { pid, psid, replayId } = data + + await this.analyticsService.checkProjectAccess( + pid, + uid, + headers['x-password'], + ) + + await this.analyticsService.checkBillingAccess(pid) + + const ip = getIPFromHeaders(headers) || requestIp || '' + await checkRateLimit(uid || ip || pid, 'session-replay-export-user', 5, 60) + await checkRateLimit(pid, 'session-replay-export-project', 20, 60) + + this.logger.log( + `pid: ${pid}, psid: ${psid}, replayId: ${replayId || 'latest'}`, + 'POST /analytics/session-replay/export', + ) + + return this.sessionReplayExportService.startExport(pid, psid, replayId) + } + + @Get('session-replay/export/:exportId') + @Auth(true, true) + async getSessionReplayExport( + @Param('exportId') exportId: string, + @CurrentUserId() uid: string | null, + @Headers() headers: { 'x-password'?: string }, + ) { + const exportState = + await this.sessionReplayExportService.assertExportAccess(exportId) + + await this.analyticsService.checkProjectAccess( + exportState.pid, + uid, + headers['x-password'], + ) + + await this.analyticsService.checkBillingAccess(exportState.pid) + + return this.sessionReplayExportService.getExport(exportId) + } + + @Get('session-replay/export/:exportId/download') + @Auth(true, true) + async downloadSessionReplayExport( + @Param('exportId') exportId: string, + @CurrentUserId() uid: string | null, + @Headers() headers: { 'x-password'?: string }, + @Response({ passthrough: true }) response: ExpressResponse, + ): Promise { + const exportState = + await this.sessionReplayExportService.assertExportAccess(exportId) + + await this.analyticsService.checkProjectAccess( + exportState.pid, + uid, + headers['x-password'], + ) + + await this.analyticsService.checkBillingAccess(exportState.pid) + + return this.sessionReplayExportService.downloadExport(exportId, response) + } + @Get('profiles') @Auth(true, true) async getProfiles( diff --git a/backend/apps/cloud/src/analytics/analytics.module.ts b/backend/apps/cloud/src/analytics/analytics.module.ts index 3e348ce82..7adc7bcfa 100644 --- a/backend/apps/cloud/src/analytics/analytics.module.ts +++ b/backend/apps/cloud/src/analytics/analytics.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common' import { TypeOrmModule } from '@nestjs/typeorm' +import { BullModule } from '@nestjs/bullmq' import { AnalyticsService } from './analytics.service' import { AnalyticsController } from './analytics.controller' @@ -7,11 +8,17 @@ import { BotDetectionService } from './bot-detection.service' import { HeartbeatGateway } from './heartbeat.gateway' import { SaltService } from './salt.service' import { Salt } from './entities/salt.entity' +import { SessionReplayS3Service } from './session-replay-s3.service' import { UserModule } from '../user/user.module' import { AppLoggerModule } from '../logger/logger.module' import { ProjectModule } from '../project/project.module' import { RevenueModule } from '../revenue/revenue.module' import { ExperimentModule } from '../experiment/experiment.module' +import { + SessionReplayExportService, + SESSION_REPLAY_EXPORT_QUEUE, +} from './session-replay-export.service' +import { SessionReplayExportProcessor } from './session-replay-export.processor' @Module({ imports: [ @@ -21,11 +28,15 @@ import { ExperimentModule } from '../experiment/experiment.module' ProjectModule, forwardRef(() => RevenueModule), forwardRef(() => ExperimentModule), + BullModule.registerQueue({ name: SESSION_REPLAY_EXPORT_QUEUE }), ], providers: [ AnalyticsService, BotDetectionService, SaltService, + SessionReplayS3Service, + SessionReplayExportService, + SessionReplayExportProcessor, HeartbeatGateway, ], exports: [AnalyticsService, BotDetectionService, SaltService], diff --git a/backend/apps/cloud/src/analytics/analytics.service.ts b/backend/apps/cloud/src/analytics/analytics.service.ts index 596c10901..0dcc94ba3 100644 --- a/backend/apps/cloud/src/analytics/analytics.service.ts +++ b/backend/apps/cloud/src/analytics/analytics.service.ts @@ -1,4 +1,5 @@ import crypto from 'crypto' +import { gzipSync, gunzipSync } from 'zlib' import _isEmpty from 'lodash/isEmpty' import _split from 'lodash/split' import _reverse from 'lodash/reverse' @@ -33,10 +34,16 @@ import { HttpStatus, UnprocessableEntityException, PreconditionFailedException, + PayloadTooLargeException, } from '@nestjs/common' import { isbot } from 'isbot' -import { DEFAULT_TIMEZONE, PlanCode } from '../user/entities/user.entity' +import { + DEFAULT_TIMEZONE, + PlanCode, + getSessionReplayQuota, + getSessionReplayRetentionEntitlement, +} from '../user/entities/user.entity' import { redis, UNIQUE_SESSION_LIFE_TIME, @@ -113,6 +120,8 @@ import { ProjectViewCustomEventDto } from '../project/dto/create-project-view.dt import { UAParser } from '@ua-parser-js/pro-business' import { extensions } from './utils/ua-parser' import { In, Not } from 'typeorm' +import { SessionReplayS3Service } from './session-replay-s3.service' +import { SessionReplayPrivacyMode } from './dto/session-replay.dto' dayjs.extend(utc) dayjs.extend(dayjsTimezone) @@ -131,6 +140,142 @@ const isValidLocale = (lc: string): boolean => { const LIVE_SESSION_THRESHOLD_SECONDS = 120 const MAX_FILTERS = 100 const MAX_FILTER_VALUES = 100 +const SESSION_REPLAY_RETENTION_VALUES = [30, 90, 365, 1825] as const +const MAX_SESSION_REPLAY_EVENTS_PER_CHUNK = 1000 +const MAX_SESSION_REPLAY_CHUNK_BYTES = 900 * 1024 +const MAX_SESSION_REPLAY_CHUNKS_PER_REPLAY = 1200 +const MAX_SESSION_REPLAY_EVENTS_PER_REPLAY = 100000 +const MAX_SESSION_REPLAY_BYTES_PER_REPLAY = 150 * 1024 * 1024 +const MAX_SESSION_REPLAY_DURATION_MS = 30 * 60 * 1000 +const SESSION_REPLAY_CLEANUP_BATCH_SIZE = 500 +const SESSION_REPLAY_CHUNK_FETCH_CONCURRENCY = 6 +const RESERVE_REPLAY_USAGE_LUA = ` +local reservationKey = KEYS[1] +local counterKey = KEYS[2] +local quota = tonumber(ARGV[1]) +local monthlyUsage = tonumber(ARGV[2]) or 0 +local ttl = tonumber(ARGV[3]) + +if redis.call("EXISTS", reservationKey) == 1 then + return 0 +end + +local reservedUsage = tonumber(redis.call("GET", counterKey) or "0") +local currentUsage = reservedUsage +if monthlyUsage > currentUsage then + currentUsage = monthlyUsage +end + +if currentUsage >= quota then + return -1 +end + +redis.call("SET", reservationKey, "1", "EX", ttl) +redis.call("SET", counterKey, currentUsage + 1, "EX", ttl) +return 1 +` +const RELEASE_REPLAY_USAGE_LUA = ` +if redis.call("DEL", KEYS[1]) == 1 then + local current = tonumber(redis.call("GET", KEYS[2]) or "0") + if current > 0 then + redis.call("DECR", KEYS[2]) + end + return 1 +end +return 0 +` +const RESERVE_REPLAY_CHUNK_LUA = ` +local chunkSetKey = KEYS[1] +local countersKey = KEYS[2] +local firstTsKey = KEYS[3] +local lastTsKey = KEYS[4] +local maxChunks = tonumber(ARGV[1]) +local maxEvents = tonumber(ARGV[2]) +local maxBytes = tonumber(ARGV[3]) +local maxDurationMs = tonumber(ARGV[4]) +local chunkIndex = ARGV[5] +local eventCount = tonumber(ARGV[6]) +local uncompressedBytes = tonumber(ARGV[7]) +local firstTimestamp = tonumber(ARGV[8]) +local lastTimestamp = tonumber(ARGV[9]) +local ttl = tonumber(ARGV[10]) + +if redis.call("SADD", chunkSetKey, chunkIndex) == 0 then + return -4 +end + +local chunks = redis.call("HINCRBY", countersKey, "chunks", 1) +local events = redis.call("HINCRBY", countersKey, "events", eventCount) +local bytes = redis.call("HINCRBY", countersKey, "bytes", uncompressedBytes) + +if firstTimestamp ~= nil then + redis.call("ZADD", firstTsKey, firstTimestamp, chunkIndex) +end + +if lastTimestamp ~= nil then + redis.call("ZADD", lastTsKey, lastTimestamp, chunkIndex) +end + +local first = redis.call("ZRANGE", firstTsKey, 0, 0, "WITHSCORES") +local last = redis.call("ZREVRANGE", lastTsKey, 0, 0, "WITHSCORES") +local duration = 0 + +if first[2] ~= nil and last[2] ~= nil then + duration = tonumber(last[2]) - tonumber(first[2]) +end + +local result = 1 +if chunks > maxChunks then + result = -1 +elseif events > maxEvents then + result = -2 +elseif bytes > maxBytes then + result = -3 +elseif duration > maxDurationMs then + result = -5 +end + +if result ~= 1 then + redis.call("SREM", chunkSetKey, chunkIndex) + redis.call("HINCRBY", countersKey, "chunks", -1) + redis.call("HINCRBY", countersKey, "events", -eventCount) + redis.call("HINCRBY", countersKey, "bytes", -uncompressedBytes) + redis.call("ZREM", firstTsKey, chunkIndex) + redis.call("ZREM", lastTsKey, chunkIndex) + return result +end + +redis.call("EXPIRE", chunkSetKey, ttl) +redis.call("EXPIRE", countersKey, ttl) +redis.call("EXPIRE", firstTsKey, ttl) +redis.call("EXPIRE", lastTsKey, ttl) +return 1 +` +const RELEASE_REPLAY_CHUNK_LUA = ` +local chunkSetKey = KEYS[1] +local countersKey = KEYS[2] +local firstTsKey = KEYS[3] +local lastTsKey = KEYS[4] +local chunkIndex = ARGV[1] +local eventCount = tonumber(ARGV[2]) +local uncompressedBytes = tonumber(ARGV[3]) + +if redis.call("SREM", chunkSetKey, chunkIndex) ~= 1 then + return 0 +end + +local chunks = redis.call("HINCRBY", countersKey, "chunks", -1) +redis.call("HINCRBY", countersKey, "events", -eventCount) +redis.call("HINCRBY", countersKey, "bytes", -uncompressedBytes) +redis.call("ZREM", firstTsKey, chunkIndex) +redis.call("ZREM", lastTsKey, chunkIndex) + +if chunks <= 0 then + redis.call("DEL", chunkSetKey, countersKey, firstTsKey, lastTsKey) +end + +return 1 +` const SOFTWARE_WITH_PATCH_VERSION = ['GameVault'] @@ -139,6 +284,29 @@ type FunnelStepDetails = Record< Record> > +const mapLimit = async ( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise => { + const results: R[] = new Array(items.length) + const concurrency = Math.max(1, Math.min(limit, items.length)) + let nextIndex = 0 + + const workers = Array.from({ length: concurrency }).map(async () => { + while (true) { + const current = nextIndex++ + if (current >= items.length) { + break + } + results[current] = await fn(items[current], current) + } + }) + + await Promise.all(workers) + return results +} + const GMT_0_TIMEZONES = [ 'Atlantic/Azores', 'Etc/GMT', @@ -438,6 +606,7 @@ export class AnalyticsService { private readonly saltService: SaltService, private readonly botDetectionService: BotDetectionService, private readonly logger: AppLoggerService, + private readonly sessionReplayStorage: SessionReplayS3Service, ) {} async checkBot( @@ -685,7 +854,11 @@ export class AnalyticsService { } async validate( - logDTO: PageviewsDto | EventsDto | ErrorDto, + logDTO: + | PageviewsDto + | EventsDto + | ErrorDto + | { pid: string; lc?: string | null }, origin: string, ip?: string, ): Promise { @@ -1634,6 +1807,704 @@ export class AnalyticsService { } } + isValidSessionReplayRetentionDays( + value: number, + ): value is 30 | 90 | 365 | 1825 { + return SESSION_REPLAY_RETENTION_VALUES.includes(value as any) + } + + private getSessionReplayRetention(project: Project): { + retentionDays: number + expiresAt: string + } { + const configuredRetentionDays = this.isValidSessionReplayRetentionDays( + project.sessionReplayRetentionDays, + ) + ? project.sessionReplayRetentionDays + : 30 + const retentionDays = Math.min( + configuredRetentionDays, + getSessionReplayRetentionEntitlement(project.admin), + ) + + return { + retentionDays, + expiresAt: dayjs + .utc() + .add(retentionDays, 'day') + .format('YYYY-MM-DD HH:mm:ss'), + } + } + + private async resolveReplaySession( + pid: string, + userAgent: string, + ip: string, + ): Promise { + const [, psid] = await this.generateAndStoreSessionId(pid, userAgent, ip) + return psid + } + + async startSessionReplay( + project: Project, + pid: string, + replayId: string, + privacy: SessionReplayPrivacyMode, + userAgent: string, + ip: string, + profileId?: string, + ) { + if (!this.sessionReplayStorage.isConfigured()) { + throw new InternalServerErrorException( + 'Session replay storage is not configured', + ) + } + + const psid = await this.resolveReplaySession(pid, userAgent, ip) + const resolvedProfileId = await this.generateProfileId( + pid, + userAgent, + ip, + profileId, + ) + + await this.recordSessionActivity(psid, pid, resolvedProfileId) + + const retention = this.getSessionReplayRetention(project) + + return { + replayId, + psid, + privacy, + ...retention, + } + } + + private getSessionReplayObjectKey( + pid: string, + psid: string, + replayId: string, + chunkIndex: number, + ): string { + const date = dayjs.utc() + return [ + 'session-replays', + pid, + date.format('YYYY'), + date.format('MM'), + psid, + replayId, + `${chunkIndex}.json.gz`, + ].join('/') + } + + private getReplayUsageKey( + accountId: string, + pid: string, + psid: string, + replayId: string, + ) { + return `session-replay:usage:${dayjs.utc().format('YYYY-MM')}:${accountId}:${pid}:${psid}:${replayId}` + } + + private getReplayUsageCounterKey(accountId: string) { + return `session-replay:usage-count:${dayjs.utc().format('YYYY-MM')}:${accountId}` + } + + private getReplayUsageTtlSeconds() { + return Math.max(60, dayjs.utc().endOf('month').diff(dayjs.utc(), 'second')) + } + + private getReplayLimitKeys(pid: string, psid: string, replayId: string) { + const prefix = `session-replay:limits:${pid}:${psid}:${replayId}` + return { + chunkSetKey: `${prefix}:chunks`, + countersKey: `${prefix}:counters`, + firstTsKey: `${prefix}:first`, + lastTsKey: `${prefix}:last`, + } + } + + private getReplayLimitTtlSeconds(retention: { expiresAt: string }) { + return Math.max( + 60, + dayjs.utc(retention.expiresAt).diff(dayjs.utc(), 'second'), + ) + } + + private getReplayChunkTimestamps(events: Record[]) { + const timestamps = events + .map((event) => + typeof event.timestamp === 'number' ? event.timestamp : null, + ) + .filter((timestamp): timestamp is number => timestamp !== null) + + return { + firstEventTimestamp: timestamps.length ? Math.min(...timestamps) : null, + lastEventTimestamp: timestamps.length ? Math.max(...timestamps) : null, + } + } + + private async getReplayAccountProjectIds( + project: Project, + pid: string, + ): Promise { + const adminId = project.admin?.id + if (!adminId) { + return [pid] + } + + const projectIds = await this.projectService.getProjectIdsByAdminId(adminId) + return Array.from(new Set([pid, ...projectIds])).filter(Boolean) + } + + private async getMonthlyReplayUsage(pids: string[]): Promise { + if (_isEmpty(pids)) { + return 0 + } + + const monthStart = dayjs + .utc() + .startOf('month') + .format('YYYY-MM-DD HH:mm:ss') + + const { data } = await clickhouse + .query({ + query: ` + SELECT uniqExact(concat(toString(psid), ':', replayId)) AS usage + FROM session_replay_chunks + WHERE pid IN {pids:Array(FixedString(12))} + AND created >= {monthStart:DateTime} + `, + query_params: { pids, monthStart }, + }) + .then((resultSet) => resultSet.json<{ usage: number }>()) + + return Number(data[0]?.usage) || 0 + } + + private async reserveReplayUsage( + project: Project, + pid: string, + psid: string, + replayId: string, + ): Promise<{ reserved: boolean; key?: string; counterKey?: string }> { + const accountId = project.admin?.id || pid + const key = this.getReplayUsageKey(accountId, pid, psid, replayId) + const quota = getSessionReplayQuota(project.admin) + + if (quota === 'custom') { + const result = await redis.set( + key, + '1', + 'EX', + this.getReplayUsageTtlSeconds(), + 'NX', + ) + + return result === 'OK' ? { reserved: true, key } : { reserved: false } + } + + const counterKey = this.getReplayUsageCounterKey(accountId) + const pids = await this.getReplayAccountProjectIds(project, pid) + const usage = await this.getMonthlyReplayUsage(pids) + const result = await redis.eval( + RESERVE_REPLAY_USAGE_LUA, + 2, + key, + counterKey, + quota, + usage, + this.getReplayUsageTtlSeconds(), + ) + + if (Number(result) === -1) { + throw new HttpException( + 'Monthly session replay quota exceeded', + HttpStatus.PAYMENT_REQUIRED, + ) + } + + if (Number(result) !== 1) { + return { reserved: false } + } + + return { reserved: true, key, counterKey } + } + + private async releaseReplayUsageReservation(reservation: { + key?: string + counterKey?: string + }) { + if (!reservation.key) { + return + } + + if (!reservation.counterKey) { + await redis.del(reservation.key) + return + } + + await redis.eval( + RELEASE_REPLAY_USAGE_LUA, + 2, + reservation.key, + reservation.counterKey, + ) + } + + private async reserveReplayChunkStorage( + pid: string, + psid: string, + replayId: string, + chunkIndex: number, + eventCount: number, + uncompressedBytes: number, + retention: { expiresAt: string }, + timestamps: { + firstEventTimestamp: number | null + lastEventTimestamp: number | null + }, + ) { + const keys = this.getReplayLimitKeys(pid, psid, replayId) + const result = await redis.eval( + RESERVE_REPLAY_CHUNK_LUA, + 4, + keys.chunkSetKey, + keys.countersKey, + keys.firstTsKey, + keys.lastTsKey, + MAX_SESSION_REPLAY_CHUNKS_PER_REPLAY, + MAX_SESSION_REPLAY_EVENTS_PER_REPLAY, + MAX_SESSION_REPLAY_BYTES_PER_REPLAY, + MAX_SESSION_REPLAY_DURATION_MS, + chunkIndex, + eventCount, + uncompressedBytes, + timestamps.firstEventTimestamp ?? '', + timestamps.lastEventTimestamp ?? '', + this.getReplayLimitTtlSeconds(retention), + ) + + switch (Number(result)) { + case 1: + return { + ...keys, + chunkIndex, + eventCount, + uncompressedBytes, + duplicate: false, + } + case -1: + throw new PayloadTooLargeException('Session replay has too many chunks') + case -2: + throw new PayloadTooLargeException('Session replay has too many events') + case -3: + throw new PayloadTooLargeException('Session replay is too large') + case -4: + return { + ...keys, + chunkIndex, + eventCount, + uncompressedBytes, + duplicate: true, + } + case -5: + throw new PayloadTooLargeException('Session replay is too long') + default: + throw new InternalServerErrorException( + 'Unable to reserve session replay chunk', + ) + } + } + + private async releaseReplayChunkStorageReservation(reservation: { + chunkSetKey: string + countersKey: string + firstTsKey: string + lastTsKey: string + chunkIndex: number + eventCount: number + uncompressedBytes: number + }) { + await redis.eval( + RELEASE_REPLAY_CHUNK_LUA, + 4, + reservation.chunkSetKey, + reservation.countersKey, + reservation.firstTsKey, + reservation.lastTsKey, + reservation.chunkIndex, + reservation.eventCount, + reservation.uncompressedBytes, + ) + } + + private validateSessionReplayReadSize(replay: { + chunkCount: number + eventCount: number + uncompressedBytes: number + replayDuration: number + }) { + if ( + replay.chunkCount > MAX_SESSION_REPLAY_CHUNKS_PER_REPLAY || + replay.eventCount > MAX_SESSION_REPLAY_EVENTS_PER_REPLAY || + replay.uncompressedBytes > MAX_SESSION_REPLAY_BYTES_PER_REPLAY || + replay.replayDuration * 1000 > MAX_SESSION_REPLAY_DURATION_MS + ) { + throw new PayloadTooLargeException('Session replay is too large to load') + } + } + + async storeSessionReplayChunk( + project: Project, + pid: string, + replayId: string, + privacy: SessionReplayPrivacyMode, + chunkIndex: number, + events: Record[], + userAgent: string, + ip: string, + ) { + if (!this.sessionReplayStorage.isConfigured()) { + throw new InternalServerErrorException( + 'Session replay storage is not configured', + ) + } + + if (events.length > MAX_SESSION_REPLAY_EVENTS_PER_CHUNK) { + throw new BadRequestException('Session replay chunk has too many events') + } + + const psid = await this.resolveReplaySession(pid, userAgent, ip) + const retention = this.getSessionReplayRetention(project) + const payload = JSON.stringify({ events }) + const uncompressedBytes = Buffer.byteLength(payload) + const timestamps = this.getReplayChunkTimestamps(events) + + if (uncompressedBytes > MAX_SESSION_REPLAY_CHUNK_BYTES) { + throw new PayloadTooLargeException('Session replay chunk is too large') + } + + const chunkReservation = await this.reserveReplayChunkStorage( + pid, + psid, + replayId, + chunkIndex, + events.length, + uncompressedBytes, + retention, + timestamps, + ) + + if (chunkReservation.duplicate) { + return { + replayId, + psid, + chunkIndex, + eventCount: events.length, + ...retention, + countedUsage: false, + } + } + + const objectKey = this.getSessionReplayObjectKey( + pid, + psid, + replayId, + chunkIndex, + ) + const compressed = gzipSync(Buffer.from(payload)) + let usageReservation: Awaited< + ReturnType + > | null = null + + try { + usageReservation = await this.reserveReplayUsage( + project, + pid, + psid, + replayId, + ) + + await this.sessionReplayStorage.putObject(objectKey, compressed) + + await clickhouse.insert({ + table: 'session_replay_chunks', + format: 'JSONEachRow', + values: [ + { + pid, + psid, + replayId, + chunkIndex, + objectKey, + privacyMode: privacy, + eventCount: events.length, + uncompressedBytes, + compressedBytes: compressed.byteLength, + firstEventTimestamp: timestamps.firstEventTimestamp, + lastEventTimestamp: timestamps.lastEventTimestamp, + created: dayjs.utc().format('YYYY-MM-DD HH:mm:ss'), + expiresAt: retention.expiresAt, + }, + ], + clickhouse_settings: { async_insert: 1, wait_for_async_insert: 1 }, + }) + } catch (reason) { + await Promise.allSettled([ + this.releaseReplayChunkStorageReservation(chunkReservation), + usageReservation + ? this.releaseReplayUsageReservation(usageReservation) + : Promise.resolve(), + this.sessionReplayStorage.deleteObject(objectKey), + ]) + throw reason + } + + return { + replayId, + psid, + chunkIndex, + eventCount: events.length, + ...retention, + countedUsage: usageReservation?.reserved || false, + } + } + + async getSessionReplaySummary(pid: string, psid: string, replayId?: string) { + const { data } = await clickhouse + .query({ + query: ` + SELECT + replayId, + argMax(privacyMode, latestCreated) AS privacyMode, + count() AS chunkCount, + sum(eventCount) AS eventCount, + sum(uncompressedBytes) AS uncompressedBytes, + min(firstEventTimestamp) AS firstEventTimestamp, + max(lastEventTimestamp) AS lastEventTimestamp, + max(chunkExpiresAt) AS replayExpiresAt, + max(latestCreated) AS lastCreated + FROM ( + SELECT + replayId, + chunkIndex, + argMax(privacyMode, created) AS privacyMode, + argMax(eventCount, created) AS eventCount, + argMax(uncompressedBytes, created) AS uncompressedBytes, + argMax(firstEventTimestamp, created) AS firstEventTimestamp, + argMax(lastEventTimestamp, created) AS lastEventTimestamp, + argMax(expiresAt, created) AS chunkExpiresAt, + max(created) AS latestCreated + FROM session_replay_chunks + WHERE pid = {pid:FixedString(12)} + AND psid = toUInt64OrNull({psid:String}) + ${replayId ? 'AND replayId = {replayId:String}' : ''} + AND expiresAt > now() + GROUP BY replayId, chunkIndex + ) + GROUP BY replayId + ORDER BY lastCreated DESC + LIMIT 1 + `, + query_params: { pid, psid, replayId: replayId || '' }, + }) + .then((resultSet) => + resultSet.json<{ + replayId: string + privacyMode: SessionReplayPrivacyMode + chunkCount: number + eventCount: number + uncompressedBytes: number + firstEventTimestamp: number | string | null + lastEventTimestamp: number | string | null + replayExpiresAt: string + }>(), + ) + + const replay = data[0] + if (!replay) { + return null + } + + const first = Number(replay.firstEventTimestamp) + const last = Number(replay.lastEventTimestamp) + const duration = + Number.isFinite(first) && Number.isFinite(last) && last > first + ? Math.round((last - first) / 1000) + : await this.getSessionDurationFromClickHouse(psid, pid) + + return { + hasReplay: true, + replayId: replay.replayId, + privacyMode: replay.privacyMode, + chunkCount: Number(replay.chunkCount) || 0, + eventCount: Number(replay.eventCount) || 0, + uncompressedBytes: Number(replay.uncompressedBytes) || 0, + replayDuration: duration || 0, + replayExpiresAt: replay.replayExpiresAt, + } + } + + async getSessionReplay( + pid: string, + psid: string, + replayId?: string, + ): Promise<{ + replay: Awaited> + events: Record[] + }> { + const replay = await this.getSessionReplaySummary(pid, psid, replayId) + const selectedReplayId = replay?.replayId + + if (!selectedReplayId) { + return { replay: null, events: [] } + } + + this.validateSessionReplayReadSize(replay) + + const { data: chunks } = await clickhouse + .query({ + query: ` + SELECT + chunkIndex, + argMax(objectKey, created) AS objectKey + FROM session_replay_chunks + WHERE pid = {pid:FixedString(12)} + AND psid = toUInt64OrNull({psid:String}) + AND replayId = {replayId:String} + AND expiresAt > now() + GROUP BY chunkIndex + ORDER BY chunkIndex ASC + `, + query_params: { pid, psid, replayId: selectedReplayId }, + }) + .then((resultSet) => + resultSet.json<{ chunkIndex: number; objectKey: string }>(), + ) + + const chunkEvents = await mapLimit( + chunks, + SESSION_REPLAY_CHUNK_FETCH_CONCURRENCY, + async (chunk) => { + const compressed = await this.sessionReplayStorage.getObject( + chunk.objectKey, + ) + if (!compressed) return [] + + const parsed = JSON.parse(gunzipSync(compressed).toString('utf8')) as { + events?: Record[] + } + + return Array.isArray(parsed.events) ? parsed.events : [] + }, + ) + + const events = chunkEvents.flat() + + return { + replay, + events, + } + } + + async deleteSessionReplay(pid: string, psid: string, replayId?: string) { + if (!this.sessionReplayStorage.isConfigured()) { + throw new InternalServerErrorException( + 'Session replay storage is not configured', + ) + } + + const replay = await this.getSessionReplaySummary(pid, psid, replayId) + const selectedReplayId = replay?.replayId + + if (!selectedReplayId) { + return { deleted: false, deletedChunks: 0 } + } + + const { data: chunks } = await clickhouse + .query({ + query: ` + SELECT DISTINCT objectKey + FROM session_replay_chunks + WHERE pid = {pid:FixedString(12)} + AND psid = toUInt64OrNull({psid:String}) + AND replayId = {replayId:String} + `, + query_params: { pid, psid, replayId: selectedReplayId }, + }) + .then((resultSet) => resultSet.json<{ objectKey: string }>()) + + const objectKeys = chunks.map((chunk) => chunk.objectKey).filter(Boolean) + + await clickhouse.command({ + query: ` + ALTER TABLE session_replay_chunks + DELETE WHERE pid = {pid:FixedString(12)} + AND psid = toUInt64OrNull({psid:String}) + AND replayId = {replayId:String} + `, + query_params: { pid, psid, replayId: selectedReplayId }, + clickhouse_settings: { mutations_sync: '2' }, + }) + + await mapLimit(objectKeys, SESSION_REPLAY_CHUNK_FETCH_CONCURRENCY, (key) => + this.sessionReplayStorage.deleteObject(key), + ) + + return { deleted: true, deletedChunks: objectKeys.length } + } + + async cleanupExpiredSessionReplays( + limit = SESSION_REPLAY_CLEANUP_BATCH_SIZE, + ): Promise { + if (!this.sessionReplayStorage.isConfigured()) { + return 0 + } + + const { data } = await clickhouse + .query({ + query: ` + SELECT objectKey + FROM session_replay_chunks + WHERE expiresAt <= now() + LIMIT {limit:UInt32} + `, + query_params: { limit }, + }) + .then((resultSet) => resultSet.json<{ objectKey: string }>()) + + if (_isEmpty(data)) { + return 0 + } + + const objectKeys = data.map((row) => row.objectKey) + + const deleteResults = await Promise.allSettled( + objectKeys.map((objectKey) => + this.sessionReplayStorage.deleteObject(objectKey), + ), + ) + const deletedObjectKeys = objectKeys.filter( + (_, index) => deleteResults[index].status === 'fulfilled', + ) + + if (_isEmpty(deletedObjectKeys)) { + return 0 + } + + await clickhouse.command({ + query: ` + ALTER TABLE session_replay_chunks + DELETE WHERE objectKey IN ({objectKeys:Array(String)}) + `, + query_params: { objectKeys: deletedObjectKeys }, + }) + + return deletedObjectKeys.length + } + formatFunnel(data: IFunnelCHResponse[], pages: string[]): IFunnel[] { const funnel = _map(data, (row, index) => { const value = pages[index] @@ -5684,6 +6555,8 @@ export class AnalyticsService { isLive = lastActivityTime.isAfter(liveThresholdTime) } + const replay = await this.getSessionReplaySummary(pid, psid) + return { pages: this.processPageflow(pages), details: { @@ -5694,6 +6567,7 @@ export class AnalyticsService { psid, chart: chartData, timeBucket, + replay, } } @@ -5788,6 +6662,19 @@ export class AnalyticsService { WHERE pid = {pid:FixedString(12)} GROUP BY psidCasted, pid ), + replay_summary AS ( + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + countDistinct(replayId) AS replayCount, + min(firstEventTimestamp) AS firstReplayTimestamp, + max(lastEventTimestamp) AS lastReplayTimestamp, + max(expiresAt) AS replayExpiresAt + FROM session_replay_chunks + WHERE pid = {pid:FixedString(12)} + AND expiresAt > now() + GROUP BY psidCasted, pid + ), first_session_per_profile AS ( SELECT profileId, @@ -5813,13 +6700,27 @@ export class AnalyticsService { dsf.sessionStart AS sessionStart, dsf.lastActivity AS lastActivity, sda.avg_duration AS sdur, - coalesce(nullIf(sda.profileId, ''), dsf.profileId) AS profileId + coalesce(nullIf(sda.profileId, ''), dsf.profileId) AS profileId, + COALESCE(rs.replayCount, 0) AS replayCount, + if( + COALESCE(rs.replayCount, 0) = 0, + NULL, + if( + isNull(rs.firstReplayTimestamp) + OR isNull(rs.lastReplayTimestamp) + OR rs.lastReplayTimestamp <= rs.firstReplayTimestamp, + ifNull(sda.avg_duration, 0), + intDiv(rs.lastReplayTimestamp - rs.firstReplayTimestamp, 1000) + ) + ) AS replayDuration, + rs.replayExpiresAt AS replayExpiresAt FROM distinct_sessions_filtered dsf LEFT JOIN pageview_counts pc ON dsf.psidCasted = pc.psidCasted AND dsf.pid = pc.pid LEFT JOIN event_counts ec ON dsf.psidCasted = ec.psidCasted AND dsf.pid = ec.pid LEFT JOIN error_counts errc ON dsf.psidCasted = errc.psidCasted AND dsf.pid = errc.pid LEFT JOIN revenue_totals rt ON dsf.psidCasted = rt.psidCasted AND dsf.pid = rt.pid LEFT JOIN session_duration_agg sda ON dsf.psidCasted = sda.psidCasted AND dsf.pid = sda.pid + LEFT JOIN replay_summary rs ON dsf.psidCasted = rs.psidCasted AND dsf.pid = rs.pid ) SELECT se.psidCasted AS psid, @@ -5841,7 +6742,10 @@ export class AnalyticsService { ifNull(se.profileId, '') = '', 0, if(isNull(fsp.firstPsid) OR fsp.firstPsid = '' OR fsp.firstPsid = se.psidCasted, 1, 0) - ) AS isFirstSession + ) AS isFirstSession, + if(se.replayCount > 0, 1, 0) AS hasReplay, + se.replayDuration, + se.replayExpiresAt FROM sessions_enriched se LEFT JOIN first_session_per_profile fsp ON se.profileId = fsp.profileId WHERE se.psidCasted IS NOT NULL @@ -5865,6 +6769,252 @@ export class AnalyticsService { return data } + async getSessionReplaysList( + filtersQuery: string, + paramsData: any, + safeTimezone: string, + take = 30, + skip = 0, + customEVFilterApplied = false, + ): Promise { + const primaryEventsSubquery = this.buildSessionsListPrimaryEventsSubquery( + filtersQuery, + customEVFilterApplied, + 'traffic', + ) + + const query = ` + WITH filtered_sessions AS ( + SELECT + psidCasted, + pid, + argMaxIf(profileId, created_for_grouping, profileId IS NOT NULL AND profileId != '') AS profileId, + any(cc) AS cc, + any(os) AS os, + any(br) AS br, + min(created_for_grouping) AS sessionStart, + max(created_for_grouping) AS lastActivity + FROM (${primaryEventsSubquery}) AS primary_events + GROUP BY psidCasted, pid + ), + replay_summary AS ( + SELECT + psidCasted, + pid, + replayId, + argMax(privacyMode, latestCreated) AS privacyMode, + count() AS chunkCount, + sum(eventCount) AS eventCount, + min(firstEventTimestamp) AS firstEventTimestamp, + max(lastEventTimestamp) AS lastEventTimestamp, + min(latestCreated) AS replayCreatedAt, + max(latestCreated) AS lastReplayCreatedAt, + max(chunkExpiresAt) AS replayExpiresAt + FROM ( + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + replayId, + chunkIndex, + argMax(privacyMode, created) AS privacyMode, + argMax(eventCount, created) AS eventCount, + argMax(firstEventTimestamp, created) AS firstEventTimestamp, + argMax(lastEventTimestamp, created) AS lastEventTimestamp, + argMax(expiresAt, created) AS chunkExpiresAt, + max(created) AS latestCreated + FROM session_replay_chunks + WHERE pid = {pid:FixedString(12)} + AND expiresAt > now() + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY psidCasted, pid, replayId, chunkIndex + ) + GROUP BY psidCasted, pid, replayId + ), + pageview_counts AS ( + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + count() as count + FROM events + WHERE pid = {pid:FixedString(12)} AND type = 'pageview' AND psid IS NOT NULL + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY psidCasted, pid + ), + event_counts AS ( + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + count() as count + FROM events + WHERE pid = {pid:FixedString(12)} AND type = 'custom_event' AND psid IS NOT NULL + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY psidCasted, pid + ), + error_counts AS ( + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + count() as count + FROM events + WHERE pid = {pid:FixedString(12)} AND type = 'error' AND psid IS NOT NULL + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY psidCasted, pid + ), + revenue_totals AS ( + SELECT + psidCasted, + pid, + sum(CASE WHEN type = 'sale' THEN amount ELSE 0 END) - sum(CASE WHEN type = 'refund' THEN abs(amount) ELSE 0 END) as revenue, + sum(CASE WHEN type = 'refund' THEN abs(amount) ELSE 0 END) as refunds + FROM ( + SELECT + CAST(session_id, 'String') AS psidCasted, + pid, + argMax(type, synced_at) AS type, + argMax(amount, synced_at) AS amount + FROM revenue + WHERE pid = {pid:FixedString(12)} AND session_id IS NOT NULL + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + AND revenue.type IN ('sale', 'refund') + GROUP BY psidCasted, pid, transaction_id + ) + GROUP BY psidCasted, pid + ), + session_duration_agg AS ( + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + dateDiff('second', min(firstSeen), max(lastSeen)) as avg_duration, + argMax(profileId, lastSeen) as profileId + FROM sessions + WHERE pid = {pid:FixedString(12)} + GROUP BY psidCasted, pid + ), + replays_enriched AS ( + SELECT + rs.psidCasted AS psidCasted, + rs.pid AS pid, + rs.replayId AS replayId, + rs.privacyMode AS privacyMode, + rs.chunkCount AS chunkCount, + rs.eventCount AS eventCount, + rs.firstEventTimestamp AS firstEventTimestamp, + rs.lastEventTimestamp AS lastEventTimestamp, + rs.replayCreatedAt AS replayCreatedAt, + rs.lastReplayCreatedAt AS lastReplayCreatedAt, + rs.replayExpiresAt AS replayExpiresAt, + fs.cc AS cc, + fs.os AS os, + fs.br AS br, + COALESCE(pc.count, 0) AS pageviews, + COALESCE(ec.count, 0) AS customEvents, + COALESCE(errc.count, 0) AS errors, + toFloat64(COALESCE(rt.revenue, toDecimal64(0, 4))) AS revenue, + toFloat64(COALESCE(rt.refunds, toDecimal64(0, 4))) AS refunds, + fs.sessionStart AS sessionStart, + fs.lastActivity AS lastActivity, + sda.avg_duration AS sdur, + coalesce(nullIf(sda.profileId, ''), fs.profileId) AS profileId + FROM replay_summary rs + INNER JOIN filtered_sessions fs ON rs.psidCasted = fs.psidCasted AND rs.pid = fs.pid + LEFT JOIN pageview_counts pc ON rs.psidCasted = pc.psidCasted AND rs.pid = pc.pid + LEFT JOIN event_counts ec ON rs.psidCasted = ec.psidCasted AND rs.pid = ec.pid + LEFT JOIN error_counts errc ON rs.psidCasted = errc.psidCasted AND rs.pid = errc.pid + LEFT JOIN revenue_totals rt ON rs.psidCasted = rt.psidCasted AND rs.pid = rt.pid + LEFT JOIN session_duration_agg sda ON rs.psidCasted = sda.psidCasted AND rs.pid = sda.pid + ), + first_session_per_profile AS ( + SELECT + profileId, + argMin(CAST(psid, 'String'), firstSeen) AS firstPsid + FROM sessions FINAL + WHERE pid = {pid:FixedString(12)} + AND profileId IS NOT NULL + AND profileId != '' + GROUP BY profileId + ) + SELECT + re.psidCasted AS psid, + re.replayId, + re.privacyMode, + re.chunkCount, + re.eventCount, + re.firstEventTimestamp, + re.lastEventTimestamp, + re.replayCreatedAt, + re.lastReplayCreatedAt, + re.replayExpiresAt, + re.cc, + re.os, + re.br, + re.pageviews, + re.customEvents, + re.errors, + re.revenue, + re.refunds, + re.sessionStart, + re.lastActivity, + if(dateDiff('second', re.lastActivity, now()) < ${LIVE_SESSION_THRESHOLD_SECONDS}, 1, 0) AS isLive, + re.sdur, + re.profileId AS profileId, + if(startsWith(ifNull(re.profileId, ''), '${AnalyticsService.PROFILE_PREFIX_USER}'), 1, 0) AS isIdentified, + if( + ifNull(re.profileId, '') = '', + 0, + if(isNull(fsp.firstPsid) OR fsp.firstPsid = '' OR fsp.firstPsid = re.psidCasted, 1, 0) + ) AS isFirstSession + FROM replays_enriched re + LEFT JOIN first_session_per_profile fsp ON re.profileId = fsp.profileId + ORDER BY re.lastReplayCreatedAt DESC, re.replayId DESC + LIMIT {take:UInt32} + OFFSET {skip:UInt32} + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { + ...paramsData.params, + timezone: safeTimezone, + take, + skip, + }, + }) + .then((resultSet) => resultSet.json()) + + return data.map((row: any) => { + const firstTimestamp = + row.firstEventTimestamp === null || + typeof row.firstEventTimestamp === 'undefined' + ? Number.NaN + : Number(row.firstEventTimestamp) + const lastTimestamp = + row.lastEventTimestamp === null || + typeof row.lastEventTimestamp === 'undefined' + ? Number.NaN + : Number(row.lastEventTimestamp) + const sessionDuration = Number(row.sdur) || 0 + const replayDuration = + Number.isFinite(firstTimestamp) && + Number.isFinite(lastTimestamp) && + lastTimestamp > firstTimestamp + ? Math.round((lastTimestamp - firstTimestamp) / 1000) + : sessionDuration + + return { + ...row, + hasReplay: 1, + chunkCount: Number(row.chunkCount) || 0, + eventCount: Number(row.eventCount) || 0, + replayDuration, + replayStart: + Number.isFinite(firstTimestamp) && firstTimestamp > 0 + ? dayjs.utc(firstTimestamp).format('YYYY-MM-DD HH:mm:ss') + : row.replayCreatedAt, + } + }) + } + private buildSessionsListPrimaryEventsSubquery( filtersQuery: string, customEVFilterApplied: boolean, diff --git a/backend/apps/cloud/src/analytics/bot-detection.service.ts b/backend/apps/cloud/src/analytics/bot-detection.service.ts index 0e740b7e1..2485bb6c8 100644 --- a/backend/apps/cloud/src/analytics/bot-detection.service.ts +++ b/backend/apps/cloud/src/analytics/bot-detection.service.ts @@ -23,6 +23,7 @@ export type BotEndpoint = | 'feature_flag' | 'heartbeat' | 'noscript' + | 'session_replay' interface BotDetectionInput { pid: string diff --git a/backend/apps/cloud/src/analytics/dto/session-replay.dto.ts b/backend/apps/cloud/src/analytics/dto/session-replay.dto.ts new file mode 100644 index 000000000..c02584dbf --- /dev/null +++ b/backend/apps/cloud/src/analytics/dto/session-replay.dto.ts @@ -0,0 +1,116 @@ +import { ApiProperty, PickType } from '@nestjs/swagger' +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator' +import { Type } from 'class-transformer' + +import { PID_REGEX } from '../../common/constants' +import { GetDataDto } from './getData.dto' + +const SESSION_REPLAY_PRIVACY_MODES = ['total', 'normal', 'none'] as const + +export type SessionReplayPrivacyMode = + (typeof SESSION_REPLAY_PRIVACY_MODES)[number] + +export class SessionReplayStartDto { + @ApiProperty({ required: true, description: 'The project ID' }) + @IsNotEmpty() + @Matches(PID_REGEX, { message: 'The provided Project ID (pid) is incorrect' }) + pid: string + + @ApiProperty({ required: true }) + @IsString() + @IsNotEmpty() + @MaxLength(80) + replayId: string + + @ApiProperty({ enum: SESSION_REPLAY_PRIVACY_MODES, default: 'total' }) + @IsIn(SESSION_REPLAY_PRIVACY_MODES) + privacy: SessionReplayPrivacyMode + + @IsOptional() + @IsString() + @MaxLength(2048) + pg?: string + + @IsOptional() + @IsString() + @MaxLength(64) + lc?: string + + @IsOptional() + @IsString() + @MaxLength(80) + tz?: string + + @IsOptional() + @IsString() + @MaxLength(256) + profileId?: string +} + +export class SessionReplayChunkDto extends SessionReplayStartDto { + @Type(() => Number) + @IsInt() + @Min(0) + @Max(100000) + chunkIndex: number + + @IsArray() + @ArrayMaxSize(1000) + @IsObject({ each: true }) + events: Record[] +} + +export class GetSessionReplayDto { + @ApiProperty({ required: true, description: 'The project ID' }) + @IsNotEmpty() + @Matches(PID_REGEX, { message: 'The provided Project ID (pid) is incorrect' }) + pid: string + + @ApiProperty() + @IsNotEmpty() + psid: string + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(80) + replayId?: string +} + +export class GetSessionReplaysDto extends PickType(GetDataDto, [ + 'pid', + 'period', + 'from', + 'to', + 'filters', + 'timezone', +] as const) { + @Type(() => Number) + @IsOptional() + @IsInt() + @Min(0) + @Max(150) + take?: number + + @Type(() => Number) + @IsOptional() + @IsInt() + @Min(0) + skip?: number +} + +export class SessionReplayExportStartDto extends GetSessionReplayDto {} diff --git a/backend/apps/cloud/src/analytics/session-replay-export.processor.ts b/backend/apps/cloud/src/analytics/session-replay-export.processor.ts new file mode 100644 index 000000000..b09258643 --- /dev/null +++ b/backend/apps/cloud/src/analytics/session-replay-export.processor.ts @@ -0,0 +1,32 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Job } from 'bullmq' + +import { + SESSION_REPLAY_EXPORT_QUEUE, + SessionReplayExportJobData, + SessionReplayExportService, +} from './session-replay-export.service' + +const concurrencyValue = Number(process.env.SESSION_REPLAY_EXPORT_CONCURRENCY) +const concurrency = + Number.isFinite(concurrencyValue) && concurrencyValue > 0 + ? Math.floor(concurrencyValue) + : 1 + +@Processor(SESSION_REPLAY_EXPORT_QUEUE, { concurrency }) +export class SessionReplayExportProcessor extends WorkerHost { + constructor( + private readonly sessionReplayExportService: SessionReplayExportService, + ) { + super() + } + + async process(job: Job): Promise { + if (job.data.type === 'cleanup') { + await this.sessionReplayExportService.cleanupExportJob(job.data) + return + } + + await this.sessionReplayExportService.processExportJob(job.data) + } +} diff --git a/backend/apps/cloud/src/analytics/session-replay-export.service.ts b/backend/apps/cloud/src/analytics/session-replay-export.service.ts new file mode 100644 index 000000000..438594c28 --- /dev/null +++ b/backend/apps/cloud/src/analytics/session-replay-export.service.ts @@ -0,0 +1,900 @@ +import { spawn } from 'child_process' +import { createHash, randomUUID } from 'crypto' +import { createReadStream } from 'fs' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + PayloadTooLargeException, + ServiceUnavailableException, + StreamableFile, +} from '@nestjs/common' +import { InjectQueue } from '@nestjs/bullmq' +import { Queue } from 'bullmq' +import type { Response } from 'express' + +import { redis } from '../common/constants' +import { hash } from '../common/utils' +import { AnalyticsService } from './analytics.service' +import { SessionReplayS3Service } from './session-replay-s3.service' + +export const SESSION_REPLAY_EXPORT_QUEUE = 'session-replay-export' + +type SessionReplayExportStatus = + | 'queued' + | 'processing' + | 'ready' + | 'failed' + | 'expired' + +export interface SessionReplayExportResponse { + exportId: string + status: SessionReplayExportStatus + progress: number + filename: string + expiresAt: string + error?: string +} + +export type SessionReplayExportJobData = + | { + type: 'export' + exportId: string + pid: string + psid: string + replayId: string + } + | { + type: 'cleanup' + exportId: string + expiresAt: string + } + +export interface SessionReplayExportState extends SessionReplayExportResponse { + pid: string + psid: string + replayId: string + objectKey?: string + createdAt: string + updatedAt: string +} + +type RrvideoEvent = Record & { + type: number + timestamp: number + data: Record +} + +const DEFAULT_MAX_DURATION_MS = 30 * 60 * 1000 +const DEFAULT_MAX_EVENTS = 100000 +const DEFAULT_MAX_INPUT_BYTES = 150 * 1024 * 1024 +const DEFAULT_MAX_OUTPUT_BYTES = 500 * 1024 * 1024 +const DEFAULT_TTL_SECONDS = 24 * 60 * 60 +const DEFAULT_RENDER_TIMEOUT_MS = 60 * 60 * 1000 +const DEFAULT_VIEWPORT = { width: 1280, height: 720 } +const TMP_DIR = path.resolve(os.tmpdir(), 'swetrix-session-replay-exports') +const EXPORT_RENDERER_VERSION = 3 +const RRWEB_EVENT_TYPE_FULL_SNAPSHOT = 2 +const RRWEB_EVENT_TYPE_INCREMENTAL_SNAPSHOT = 3 +const RRWEB_EVENT_TYPE_META = 4 +const RRWEB_INCREMENTAL_SOURCE_VIEWPORT_RESIZE = 4 + +const getPositiveEnvNumber = (key: string, fallback: number) => { + const value = Number(process.env[key]) + return Number.isFinite(value) && value > 0 ? value : fallback +} + +const getExportStateKey = (exportId: string) => + `session-replay:export:${exportId}` + +@Injectable() +export class SessionReplayExportService { + private readonly logger = new Logger(SessionReplayExportService.name) + + private readonly maxDurationMs = getPositiveEnvNumber( + 'SESSION_REPLAY_EXPORT_MAX_DURATION_MS', + DEFAULT_MAX_DURATION_MS, + ) + + private readonly maxEvents = getPositiveEnvNumber( + 'SESSION_REPLAY_EXPORT_MAX_EVENTS', + DEFAULT_MAX_EVENTS, + ) + + private readonly maxInputBytes = getPositiveEnvNumber( + 'SESSION_REPLAY_EXPORT_MAX_INPUT_BYTES', + DEFAULT_MAX_INPUT_BYTES, + ) + + private readonly maxOutputBytes = getPositiveEnvNumber( + 'SESSION_REPLAY_EXPORT_MAX_OUTPUT_BYTES', + DEFAULT_MAX_OUTPUT_BYTES, + ) + + private readonly ttlSeconds = getPositiveEnvNumber( + 'SESSION_REPLAY_EXPORT_TTL_SECONDS', + DEFAULT_TTL_SECONDS, + ) + + private readonly renderTimeoutMs = getPositiveEnvNumber( + 'SESSION_REPLAY_EXPORT_RENDER_TIMEOUT_MS', + DEFAULT_RENDER_TIMEOUT_MS, + ) + + constructor( + private readonly analyticsService: AnalyticsService, + private readonly sessionReplayStorage: SessionReplayS3Service, + @InjectQueue(SESSION_REPLAY_EXPORT_QUEUE) + private readonly exportQueue: Queue, + ) {} + + async startExport( + pid: string, + psid: string, + replayId?: string, + ): Promise { + if (!this.sessionReplayStorage.isConfigured()) { + throw new ServiceUnavailableException( + 'Session replay storage is not configured', + ) + } + + const replay = await this.analyticsService.getSessionReplaySummary( + pid, + psid, + replayId, + ) + + if (!replay?.hasReplay) { + throw new BadRequestException('Replay is no longer available') + } + + this.validateReplayMetadata(replay.eventCount, replay.replayDuration) + + const exportId = hash( + `${EXPORT_RENDERER_VERSION}:${pid}:${psid}:${replay.replayId}:${replay.eventCount}:${replay.replayDuration}`, + ) + const existing = await this.getState(exportId) + + if (existing && !this.shouldStartNewExport(existing)) { + return this.toResponse(existing) + } + + if (existing?.objectKey) { + await this.sessionReplayStorage + .deleteObject(existing.objectKey) + .catch((reason) => + this.logger.warn( + `Failed to delete stale session replay export ${exportId}: ${ + reason instanceof Error ? reason.message : String(reason) + }`, + ), + ) + } + + const now = new Date() + const state: SessionReplayExportState = { + exportId, + pid, + psid, + replayId: replay.replayId, + status: 'queued', + progress: 0, + filename: this.getFilename(psid), + expiresAt: new Date(now.getTime() + this.ttlSeconds * 1000).toISOString(), + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + } + + await this.saveState(state) + + try { + await this.exportQueue.add( + 'export-session-replay', + { + type: 'export', + exportId, + pid, + psid, + replayId: replay.replayId, + }, + { + jobId: exportId, + attempts: 1, + removeOnComplete: true, + removeOnFail: true, + }, + ) + } catch (reason) { + await this.markFailed(state, reason) + throw new ServiceUnavailableException('Failed to start export') + } + + return this.toResponse(state) + } + + async getExport(exportId: string): Promise { + const state = await this.getState(exportId) + if (!state) { + throw new NotFoundException('Export not found') + } + + if (this.isExpired(state) && state.status !== 'expired') { + await this.expireState(state) + return this.toResponse({ + ...state, + status: 'expired', + objectKey: undefined, + }) + } + + return this.toResponse(state) + } + + async downloadExport( + exportId: string, + response: Response, + ): Promise { + const state = await this.getState(exportId) + + if (!state || this.isExpired(state) || state.status === 'expired') { + throw new NotFoundException('Export not found') + } + + if (state.status !== 'ready' || !state.objectKey) { + throw new BadRequestException('Export is not ready') + } + + const object = await this.sessionReplayStorage.getObjectStream( + state.objectKey, + ) + + if (!object) { + await this.markFailed(state, new Error('Export file is missing')) + throw new NotFoundException('Export file not found') + } + + response.setHeader('Content-Type', object.contentType || 'video/mp4') + response.setHeader( + 'Content-Disposition', + `attachment; filename="${state.filename}"`, + ) + response.setHeader('Cache-Control', 'private, no-store') + if (object.contentLength) { + response.setHeader('Content-Length', object.contentLength) + } + + return new StreamableFile(object.body) + } + + async assertExportAccess( + exportId: string, + ): Promise { + const state = await this.getState(exportId) + if (!state) { + throw new NotFoundException('Export not found') + } + return state + } + + async processExportJob( + data: Extract, + ) { + const existing = await this.getState(data.exportId) + const state = + existing || + ({ + exportId: data.exportId, + pid: data.pid, + psid: data.psid, + replayId: data.replayId, + status: 'queued', + progress: 0, + filename: this.getFilename(data.psid), + expiresAt: new Date(Date.now() + this.ttlSeconds * 1000).toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } satisfies SessionReplayExportState) + + let workDir: string | null = null + + try { + await this.updateState(state, { status: 'processing', progress: 5 }) + + const { replay, events } = await this.analyticsService.getSessionReplay( + data.pid, + data.psid, + data.replayId, + ) + + if (!replay?.hasReplay || !events.length) { + throw new Error('Replay is no longer available') + } + + this.validateReplayMetadata(replay.eventCount, replay.replayDuration) + const exportEvents = this.prepareEventsForRrvideo(events) + + const durationMs = this.getReplayDurationMs( + exportEvents, + replay.replayDuration, + ) + this.validateReplayDuration(durationMs) + + const serializedEvents = JSON.stringify(exportEvents) + const inputBytes = Buffer.byteLength(serializedEvents) + + if (inputBytes > this.maxInputBytes) { + throw new Error('Replay is too large to export') + } + + workDir = path.join(TMP_DIR, `${data.exportId}-${randomUUID()}`) + await fs.mkdir(workDir, { recursive: true, mode: 0o700 }) + + const inputPath = path.join(workDir, 'events.json') + const configPath = path.join(workDir, 'rrvideo.config.json') + const webmPath = path.join(workDir, 'replay.webm') + const mp4Path = path.join(workDir, 'replay.mp4') + + await fs.writeFile(inputPath, serializedEvents, { mode: 0o600 }) + await fs.writeFile( + configPath, + JSON.stringify(this.getRrvideoConfig(exportEvents)), + { mode: 0o600 }, + ) + + await this.updateState(state, { status: 'processing', progress: 20 }) + + await this.runCommand( + this.getRrvideoBin(), + ['--input', inputPath, '--output', webmPath, '--config', configPath], + { + cwd: workDir, + rejectOutput: /Error initializing replayer|Failed to transform/i, + }, + ) + + await this.updateState(state, { status: 'processing', progress: 70 }) + + await this.runCommand( + this.getFfmpegBin(), + [ + '-y', + '-i', + webmPath, + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + mp4Path, + ], + { cwd: workDir }, + ) + + const outputStat = await fs.stat(mp4Path) + if (outputStat.size > this.maxOutputBytes) { + throw new Error('Replay export is too large to download') + } + + await this.updateState(state, { status: 'processing', progress: 90 }) + + const objectKey = this.getObjectKey(data.pid, data.psid, data.exportId) + const outputHash = await this.hashFile(mp4Path) + await this.sessionReplayStorage.putObject( + objectKey, + createReadStream(mp4Path), + 'video/mp4', + { + contentLength: outputStat.size, + payloadHash: outputHash, + }, + ) + + await this.updateState(state, { + status: 'ready', + progress: 100, + objectKey, + error: undefined, + }) + + try { + await this.exportQueue.add( + 'cleanup-session-replay-export', + { + type: 'cleanup', + exportId: data.exportId, + expiresAt: state.expiresAt, + }, + { + jobId: `${data.exportId}:cleanup:${hash(state.expiresAt)}`, + delay: this.ttlSeconds * 1000, + attempts: 3, + removeOnComplete: true, + removeOnFail: true, + }, + ) + } catch (reason) { + this.logger.warn( + `Failed to schedule session replay export cleanup ${data.exportId}: ${ + reason instanceof Error ? reason.message : String(reason) + }`, + ) + } + } catch (reason) { + await this.markFailed(state, reason) + throw reason + } finally { + if (workDir) { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => null) + } + } + } + + async cleanupExportJob( + data: Extract, + ) { + const state = await this.getState(data.exportId) + if (!state) return + + if (state.expiresAt !== data.expiresAt) return + + if (state.objectKey) { + try { + await this.sessionReplayStorage.deleteObject(state.objectKey) + } catch (reason) { + this.logger.warn( + `Failed to delete session replay export ${data.exportId}: ${ + reason instanceof Error ? reason.message : String(reason) + }`, + ) + throw reason + } + } + + await this.expireState(state) + } + + private validateReplayMetadata(eventCount: number, durationSeconds: number) { + if (eventCount > this.maxEvents) { + throw new PayloadTooLargeException('Replay has too many events to export') + } + + this.validateReplayDuration(durationSeconds * 1000) + } + + private validateReplayDuration(durationMs: number) { + if (durationMs > this.maxDurationMs) { + throw new PayloadTooLargeException('Replay is too long to export') + } + } + + private prepareEventsForRrvideo(events: Record[]) { + const normalised: Array<{ event: RrvideoEvent; index: number }> = [] + + events.forEach((event, index) => { + const normalisedEvent = this.normaliseReplayEvent(event) + if (normalisedEvent) { + normalised.push({ event: normalisedEvent, index }) + } + }) + + normalised.sort( + (a, b) => a.event.timestamp - b.event.timestamp || a.index - b.index, + ) + + if (!normalised.length) { + throw new Error('Replay is no longer available') + } + + const prepared = normalised.map(({ event }) => event) + if ( + !prepared.some((event) => event.type === RRWEB_EVENT_TYPE_FULL_SNAPSHOT) + ) { + throw new Error('Replay is missing a full snapshot') + } + + const viewport = this.getReplayViewport(prepared) + const href = this.getReplayHref(prepared) + let hasMeta = false + const withMeta = prepared.map((event) => { + if (event.type !== RRWEB_EVENT_TYPE_META) return event + + hasMeta = true + return { + ...event, + data: { + ...this.getEventData(event), + href, + width: viewport.width, + height: viewport.height, + }, + } + }) + + if (!hasMeta) { + withMeta.unshift({ + type: RRWEB_EVENT_TYPE_META, + timestamp: withMeta[0].timestamp, + data: { + href, + width: viewport.width, + height: viewport.height, + }, + }) + } + + return withMeta + } + + private normaliseReplayEvent( + event: Record, + ): RrvideoEvent | null { + const timestamp = Number(event.timestamp) + const type = this.normaliseEventType(event.type) + + if (!Number.isFinite(timestamp) || type === null) { + return null + } + + return { + ...event, + type, + timestamp, + data: this.getEventData(event), + } + } + + private normaliseEventType(value: unknown) { + const numeric = Number(value) + if (Number.isInteger(numeric)) return numeric + + if (typeof value !== 'string') return null + + const eventTypes: Record = { + DomContentLoaded: 0, + Load: 1, + FullSnapshot: RRWEB_EVENT_TYPE_FULL_SNAPSHOT, + IncrementalSnapshot: RRWEB_EVENT_TYPE_INCREMENTAL_SNAPSHOT, + Meta: RRWEB_EVENT_TYPE_META, + Custom: 5, + Plugin: 6, + Asset: 7, + } + + return eventTypes[value] ?? null + } + + private getEventData(event: Record) { + return event.data && typeof event.data === 'object' + ? (event.data as Record) + : {} + } + + private getReplayDurationMs( + events: Record[], + fallbackDurationSeconds: number, + ) { + let minTimestamp = Infinity + let maxTimestamp = -Infinity + + for (const event of events) { + const timestamp = Number(event.timestamp) + if (!Number.isFinite(timestamp)) continue + + minTimestamp = Math.min(minTimestamp, timestamp) + maxTimestamp = Math.max(maxTimestamp, timestamp) + } + + if (!Number.isFinite(minTimestamp) || !Number.isFinite(maxTimestamp)) { + return fallbackDurationSeconds * 1000 + } + + return Math.max(fallbackDurationSeconds * 1000, maxTimestamp - minTimestamp) + } + + private getRrvideoConfig(events: Record[]) { + const viewport = this.getReplayViewport(events) + + return { + width: viewport.width, + height: viewport.height, + speed: 1, + skipInactive: false, + mouseTail: true, + } + } + + private getReplayViewport(events: Record[]) { + let maxWidth = 0 + let maxHeight = 0 + + for (const event of events) { + const data = this.getEventData(event) + const isViewportEvent = + Number(event.type) === RRWEB_EVENT_TYPE_META || + (Number(event.type) === RRWEB_EVENT_TYPE_INCREMENTAL_SNAPSHOT && + Number(data.source) === RRWEB_INCREMENTAL_SOURCE_VIEWPORT_RESIZE) + + if (!isViewportEvent) continue + + const viewport = this.getViewportCandidate(data) + if (!viewport) continue + + maxWidth = Math.max(maxWidth, viewport.width) + maxHeight = Math.max(maxHeight, viewport.height) + } + + if (!maxWidth || !maxHeight) { + return DEFAULT_VIEWPORT + } + + return this.clampViewport({ width: maxWidth, height: maxHeight }) + } + + private getViewportCandidate(data: Record) { + const width = Number(data.width) + const height = Number(data.height) + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null + } + + return { + width: Math.round(width), + height: Math.round(height), + } + } + + private clampViewport(viewport: { width: number; height: number }) { + return { + width: Math.min(1920, Math.max(320, viewport.width)), + height: Math.min(1080, Math.max(240, viewport.height)), + } + } + + private getReplayHref(events: Record[]) { + for (const event of events) { + if (Number(event.type) !== RRWEB_EVENT_TYPE_META) continue + + const href = this.getEventData(event).href + if (typeof href === 'string' && href) { + return this.getSafeReplayHref(href) + } + } + + return 'about:blank' + } + + private getSafeReplayHref(href: string) { + try { + const url = new URL(href) + if (url.protocol === 'https:' || url.protocol === 'http:') { + return url.toString() + } + } catch { + return 'about:blank' + } + + return 'about:blank' + } + + private async hashFile(filePath: string) { + const digest = createHash('sha256') + await new Promise((resolve, reject) => { + const stream = createReadStream(filePath) + stream.on('data', (chunk) => digest.update(chunk)) + stream.on('error', reject) + stream.on('end', resolve) + }) + + return digest.digest('hex') + } + + private async runCommand( + command: string, + args: string[], + options: { cwd?: string; rejectOutput?: RegExp } = {}, + ) { + await new Promise((resolve, reject) => { + let settled = false + let output = '' + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const timeout = setTimeout(() => { + if (settled) return + settled = true + child.kill('SIGKILL') + reject(new Error(`${path.basename(command)} timed out`)) + }, this.renderTimeoutMs) + + const captureOutput = (chunk: Buffer) => { + output = `${output}${chunk.toString()}` + if (output.length > 8000) { + output = output.slice(-8000) + } + } + + child.stdout.on('data', captureOutput) + child.stderr.on('data', captureOutput) + + child.on('error', (reason) => { + if (settled) return + settled = true + clearTimeout(timeout) + reject(reason) + }) + + child.on('close', (code) => { + if (settled) return + settled = true + clearTimeout(timeout) + + if (code === 0) { + if (options.rejectOutput?.test(output)) { + reject( + new Error( + `${path.basename(command)} reported a renderer error: ${output}`, + ), + ) + return + } + + resolve() + return + } + + reject( + new Error( + `${path.basename(command)} failed with status ${code}: ${output}`, + ), + ) + }) + }) + } + + private getRrvideoBin() { + return ( + process.env.RRVIDEO_BIN || + path.join( + process.cwd(), + 'node_modules', + '.bin', + process.platform === 'win32' ? 'rrvideo.cmd' : 'rrvideo', + ) + ) + } + + private getFfmpegBin() { + return process.env.FFMPEG_BIN || 'ffmpeg' + } + + private getObjectKey(pid: string, psid: string, exportId: string) { + return ['session-replay-exports', pid, psid, `${exportId}.mp4`].join('/') + } + + private getFilename(psid: string) { + const safePsid = psid.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80) + return `swetrix-replay-${safePsid || 'session'}.mp4` + } + + private async getState( + exportId: string, + ): Promise { + const raw = await redis.get(getExportStateKey(exportId)) + if (!raw) return null + + try { + return JSON.parse(raw) as SessionReplayExportState + } catch { + return null + } + } + + private async saveState(state: SessionReplayExportState) { + await redis.set( + getExportStateKey(state.exportId), + JSON.stringify(state), + 'EX', + this.ttlSeconds + 24 * 60 * 60, + ) + } + + private async updateState( + state: SessionReplayExportState, + update: Partial, + ) { + const next = { + ...state, + ...update, + updatedAt: new Date().toISOString(), + } + Object.assign(state, next) + await this.saveState(next) + return next + } + + private async markFailed(state: SessionReplayExportState, reason: unknown) { + this.logger.error( + `Session replay export ${state.exportId} failed: ${this.getDetailedError( + reason, + )}`, + ) + + await this.updateState(state, { + status: 'failed', + progress: 100, + error: this.getPublicError(reason), + objectKey: undefined, + }) + } + + private async expireState(state: SessionReplayExportState) { + await this.updateState(state, { + status: 'expired', + progress: 100, + objectKey: undefined, + error: undefined, + }) + } + + private isExpired(state: SessionReplayExportState) { + return Date.parse(state.expiresAt) <= Date.now() + } + + private shouldStartNewExport(state: SessionReplayExportState) { + return ( + this.isExpired(state) || + state.status === 'expired' || + state.status === 'failed' + ) + } + + private getPublicError(reason: unknown) { + const message = this.getDetailedError(reason) + + if (message.includes('Replay is no longer available')) { + return 'Replay is no longer available' + } + + if ( + message.includes('too large') || + message.includes('too long') || + message.includes('too many events') + ) { + return 'Replay is too large to export' + } + + return 'Could not export replay to MP4' + } + + private getDetailedError(reason: unknown) { + return reason instanceof Error + ? reason.stack || reason.message + : String(reason) + } + + private toResponse( + state: SessionReplayExportState, + ): SessionReplayExportResponse { + return { + exportId: state.exportId, + status: state.status, + progress: state.progress, + filename: state.filename, + expiresAt: state.expiresAt, + ...(state.error ? { error: state.error } : {}), + } + } +} diff --git a/backend/apps/cloud/src/analytics/session-replay-s3.service.ts b/backend/apps/cloud/src/analytics/session-replay-s3.service.ts new file mode 100644 index 000000000..2c5cc922e --- /dev/null +++ b/backend/apps/cloud/src/analytics/session-replay-s3.service.ts @@ -0,0 +1,250 @@ +import crypto from 'crypto' +import { Readable } from 'stream' +import { Injectable } from '@nestjs/common' + +interface S3Config { + endpoint: string + bucket: string + region: string + accessKeyId: string + secretAccessKey: string +} + +type HeaderMap = Record +type PutObjectBody = Buffer | Readable + +const SESSION_REPLAY_STORAGE_TIMEOUT_MS = 30_000 + +const sha256Hex = (value: Buffer | string) => + crypto.createHash('sha256').update(value).digest('hex') + +const hmac = (key: Buffer | string, value: string) => + crypto.createHmac('sha256', key).update(value).digest() + +const encodeKeyPath = (key: string) => + key.split('/').map(encodeURIComponent).join('/') + +const normalizeEndpoint = (endpoint: string) => { + const value = endpoint.trim().replace(/\/+$/, '') + + return /^https?:\/\//.test(value) ? value : `https://${value}` +} + +const inferHetznerRegion = (endpoint: string) => { + const suffix = '.your-objectstorage.com' + const hostname = new URL(endpoint).hostname + + if (!hostname.endsWith(suffix)) { + return '' + } + + return hostname.slice(0, -suffix.length).split('.')[0] || '' +} + +@Injectable() +export class SessionReplayS3Service { + private getConfig(): S3Config | null { + const endpointValue = + process.env.SESSION_REPLAY_S3_ENDPOINT || process.env.HETZNER_S3_ENDPOINT + const endpoint = endpointValue ? normalizeEndpoint(endpointValue) : '' + const region = endpoint ? inferHetznerRegion(endpoint) : '' + const bucket = + process.env.SESSION_REPLAY_S3_BUCKET || process.env.HETZNER_S3_BUCKET + const accessKeyId = + process.env.SESSION_REPLAY_S3_ACCESS_KEY_ID || + process.env.HETZNER_S3_ACCESS_KEY_ID + const secretAccessKey = + process.env.SESSION_REPLAY_S3_SECRET_ACCESS_KEY || + process.env.HETZNER_S3_SECRET_ACCESS_KEY + + if (!endpoint || !bucket || !region || !accessKeyId || !secretAccessKey) { + return null + } + + return { + endpoint, + bucket, + region, + accessKeyId, + secretAccessKey, + } + } + + isConfigured(): boolean { + return Boolean(this.getConfig()) + } + + async putObject( + key: string, + body: PutObjectBody, + contentType = 'application/json', + options: { contentLength?: number; payloadHash?: string } = {}, + ): Promise { + const headers: HeaderMap = { + 'content-type': contentType, + } + + if (typeof options.contentLength === 'number') { + headers['content-length'] = String(options.contentLength) + } + + const response = await this.signedFetch( + 'PUT', + key, + body, + headers, + options.payloadHash, + ) + + if (!response.ok) { + throw new Error(`Hetzner S3 PUT failed with status ${response.status}`) + } + } + + async getObject(key: string): Promise { + const response = await this.signedFetch('GET', key) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + throw new Error(`Hetzner S3 GET failed with status ${response.status}`) + } + + return Buffer.from(await response.arrayBuffer()) + } + + async getObjectStream(key: string): Promise<{ + body: Readable + contentLength?: string + contentType?: string + } | null> { + const response = await this.signedFetch('GET', key) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + throw new Error(`Hetzner S3 GET failed with status ${response.status}`) + } + + if (!response.body) { + return null + } + + return { + body: Readable.fromWeb(response.body as any), + contentLength: response.headers.get('content-length') || undefined, + contentType: response.headers.get('content-type') || undefined, + } + } + + async deleteObject(key: string): Promise { + const response = await this.signedFetch('DELETE', key) + + if (!response.ok && response.status !== 404) { + throw new Error(`Hetzner S3 DELETE failed with status ${response.status}`) + } + } + + private async signedFetch( + method: 'PUT' | 'GET' | 'DELETE', + key: string, + body?: PutObjectBody, + extraHeaders: HeaderMap = {}, + payloadHashOverride?: string, + ): Promise { + const config = this.getConfig() + if (!config) { + throw new Error('Hetzner S3 session replay storage is not configured') + } + + const hasStreamBody = body instanceof Readable + if (hasStreamBody && !payloadHashOverride) { + throw new Error('Stream uploads require a payload hash') + } + + const payloadHash = + payloadHashOverride || + sha256Hex(body instanceof Readable ? '' : body || '') + const now = new Date() + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '') + const dateStamp = amzDate.slice(0, 8) + const endpoint = new URL(config.endpoint) + endpoint.hostname = `${config.bucket}.${endpoint.hostname}` + endpoint.pathname = `/${encodeKeyPath(key)}` + + const headers: HeaderMap = { + host: endpoint.host, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + ...extraHeaders, + } + const signedHeaderKeys = Object.keys(headers) + .map((header) => header.toLowerCase()) + .sort() + const canonicalHeaders = signedHeaderKeys + .map((header) => `${header}:${headers[header].trim()}\n`) + .join('') + const signedHeaders = signedHeaderKeys.join(';') + const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request` + const canonicalRequest = [ + method, + endpoint.pathname, + '', + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n') + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + sha256Hex(canonicalRequest), + ].join('\n') + const dateKey = hmac(`AWS4${config.secretAccessKey}`, dateStamp) + const regionKey = hmac(dateKey, config.region) + const serviceKey = hmac(regionKey, 's3') + const signingKey = hmac(serviceKey, 'aws4_request') + const signature = crypto + .createHmac('sha256', signingKey) + .update(stringToSign) + .digest('hex') + + headers.authorization = [ + `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}`, + `SignedHeaders=${signedHeaders}`, + `Signature=${signature}`, + ].join(', ') + const requestBody = body + ? hasStreamBody + ? (body as any) + : new Uint8Array(body) + : undefined + const controller = new AbortController() + const timeout = setTimeout( + () => controller.abort(), + SESSION_REPLAY_STORAGE_TIMEOUT_MS, + ) + const request: RequestInit & { duplex?: 'half' } = { + method, + headers, + signal: controller.signal, + } + + if (requestBody) { + request.body = requestBody + if (hasStreamBody) { + request.duplex = 'half' + } + } + + try { + return await fetch(endpoint, request) + } finally { + clearTimeout(timeout) + } + } +} diff --git a/backend/apps/cloud/src/logger/logger.service.ts b/backend/apps/cloud/src/logger/logger.service.ts index fc224839c..50aeb58a6 100644 --- a/backend/apps/cloud/src/logger/logger.service.ts +++ b/backend/apps/cloud/src/logger/logger.service.ts @@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common' import { isDevelopment } from '../common/constants' export class AppLoggerService extends Logger { - log(value, route, forceLog = false) { + log(value, route?: string, forceLog = false) { if (isDevelopment || forceLog) { Logger.log(value, route) } diff --git a/backend/apps/cloud/src/main.ts b/backend/apps/cloud/src/main.ts index 47d577cd6..f376827f5 100644 --- a/backend/apps/cloud/src/main.ts +++ b/backend/apps/cloud/src/main.ts @@ -2,6 +2,7 @@ import './instrument' import { NestFactory } from '@nestjs/core' import { ValidationPipe, VersioningType } from '@nestjs/common' +import { NestExpressApplication } from '@nestjs/platform-express' import cookieParser from 'cookie-parser' import express from 'express' import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger' @@ -13,9 +14,10 @@ import { validateLicense } from './common/license' async function bootstrap() { validateLicense() - const app = await NestFactory.create(AppModule) + const app = await NestFactory.create(AppModule) app.use(cookieParser()) + app.useBodyParser('json', { limit: '1mb' }) app.useGlobalPipes(new ValidationPipe()) app.enableVersioning({ diff --git a/backend/apps/cloud/src/project/dto/update-project.dto.ts b/backend/apps/cloud/src/project/dto/update-project.dto.ts index b3789b222..ede7fff04 100644 --- a/backend/apps/cloud/src/project/dto/update-project.dto.ts +++ b/backend/apps/cloud/src/project/dto/update-project.dto.ts @@ -56,4 +56,15 @@ export class UpdateProjectDto extends IntersectionType( @IsIn(['manual', 'auto']) @IsOptional() captchaDifficultyMode?: 'manual' | 'auto' + + @ApiProperty({ + required: false, + description: 'Session replay retention in days.', + enum: [30, 90, 365, 1825], + default: 30, + }) + @IsInt() + @IsIn([30, 90, 365, 1825]) + @IsOptional() + sessionReplayRetentionDays?: number } diff --git a/backend/apps/cloud/src/project/entity/project.entity.ts b/backend/apps/cloud/src/project/entity/project.entity.ts index 5acc2adf0..3f8d92803 100644 --- a/backend/apps/cloud/src/project/entity/project.entity.ts +++ b/backend/apps/cloud/src/project/entity/project.entity.ts @@ -193,6 +193,9 @@ export class Project { @Column('boolean', { default: false }) revenueApiEnabled: boolean + @Column('int', { unsigned: true, default: 30 }) + sessionReplayRetentionDays: number + @ApiProperty() @Column('varchar', { nullable: true, default: null, length: 512 }) websiteUrl: string | null diff --git a/backend/apps/cloud/src/project/project.controller.ts b/backend/apps/cloud/src/project/project.controller.ts index d732edb75..ce5bec9f2 100644 --- a/backend/apps/cloud/src/project/project.controller.ts +++ b/backend/apps/cloud/src/project/project.controller.ts @@ -114,7 +114,10 @@ import { ProjectOrganisationDto } from './dto/project-organisation.dto' import { trackCustom } from '../common/analytics' import { PendingInvitationService } from '../pending-invitation/pending-invitation.service' import { PendingInvitationType } from '../pending-invitation/pending-invitation.entity' -import { PlanCode } from '../user/entities/user.entity' +import { + PlanCode, + getSessionReplayRetentionEntitlement, +} from '../user/entities/user.entity' const PROJECTS_MAXIMUM = 50 const ORIGINS_ITEMS_MAXIMUM = 1000 @@ -1826,6 +1829,17 @@ export class ProjectController { projectDTO.captchaDifficultyMode as Project['captchaDifficultyMode'] } + if (projectDTO.sessionReplayRetentionDays !== undefined) { + const maxRetention = getSessionReplayRetentionEntitlement(project.admin) + if (projectDTO.sessionReplayRetentionDays > maxRetention) { + throw new ForbiddenException( + 'Your plan does not include this session replay retention period', + ) + } + + project.sessionReplayRetentionDays = projectDTO.sessionReplayRetentionDays + } + if (projectDTO.name) { project.name = _trim(projectDTO.name) } diff --git a/backend/apps/cloud/src/project/project.service.ts b/backend/apps/cloud/src/project/project.service.ts index 7ae04cb80..313164b8c 100644 --- a/backend/apps/cloud/src/project/project.service.ts +++ b/backend/apps/cloud/src/project/project.service.ts @@ -191,6 +191,7 @@ export class ProjectService { ) .leftJoinAndSelect('organisationMembers.user', 'organisationUser') .select([ + 'project.id', 'project.origins', 'project.active', 'project.public', @@ -200,6 +201,7 @@ export class ProjectService { 'project.captchaSecretKey', 'project.captchaDifficulty', 'project.captchaDifficultyMode', + 'project.sessionReplayRetentionDays', 'project.passwordHash', 'project.isPasswordProtected', 'admin.id', @@ -207,6 +209,8 @@ export class ProjectService { 'admin.isAccountBillingSuspended', 'admin.planCode', 'admin.planType', + 'admin.entitlementOverrides', + 'admin.addonOverrides', 'organisation.id', 'organisationMembers.role', 'organisationMembers.confirmed', @@ -278,6 +282,7 @@ export class ProjectService { isAccountBillingSuspended: true, planCode: true, planType: true, + entitlementOverrides: true, }, organisation: { id: true, @@ -1024,6 +1029,57 @@ export class ProjectService { return info as IUsageInfoRedis } + async getMonthlySessionReplayUsage(uid: string): Promise { + const monthStart = dayjs.utc().startOf('month') + const key = `monthly_replay_usage:${uid}:${monthStart.format('YYYY-MM')}` + const cachedUsage = await redis.get(key) + + if (!_isEmpty(cachedUsage)) { + return Number(cachedUsage) || 0 + } + + const projects = await this.find({ + where: { + admin: { id: uid }, + }, + select: ['id'], + }) + + if (_isEmpty(projects)) { + await redis.set(key, '0', 'EX', redisUserUsageinfoCacheTimeout) + return 0 + } + + const CHUNK_SIZE = 5000 + const pids = _map(projects, 'id') + let usage = 0 + + for (let i = 0; i < pids.length; i += CHUNK_SIZE) { + const pidChunk = pids.slice(i, i + CHUNK_SIZE) + + const { data } = await clickhouse + .query({ + query: ` + SELECT uniqExact(concat(toString(psid), ':', replayId)) AS usage + FROM session_replay_chunks + WHERE pid IN {pids:Array(FixedString(12))} + AND created >= {monthStart:DateTime} + `, + query_params: { + pids: pidChunk, + monthStart: monthStart.format('YYYY-MM-DD HH:mm:ss'), + }, + }) + .then((resultSet) => resultSet.json<{ usage: number }>()) + + usage += Number(data[0]?.usage) || 0 + } + + await redis.set(key, `${usage}`, 'EX', redisUserUsageinfoCacheTimeout) + + return usage + } + async clearProjectsRedisCache(uid: string): Promise { const projects = await this.find({ where: { diff --git a/backend/apps/cloud/src/task-manager/task-manager.service.ts b/backend/apps/cloud/src/task-manager/task-manager.service.ts index 4c71c8497..38d633695 100644 --- a/backend/apps/cloud/src/task-manager/task-manager.service.ts +++ b/backend/apps/cloud/src/task-manager/task-manager.service.ts @@ -704,7 +704,10 @@ export class TaskManagerService { async (pid, index) => { const projectData = data[pid] || {} const topCountry = topCountries[pid]?.cc || null - const errorStats = errorCounts[pid] || { count: 0, uniqueErrors: 0 } + const errorStats = errorCounts[pid] || { + count: 0, + uniqueErrors: 0, + } const pidTotalSessions = totalSessions[pid] || 0 const projectGoals = await this.goalService.findByProject(pid) @@ -1449,11 +1452,14 @@ export class TaskManagerService { @Cron(CronExpression.EVERY_HOUR) async processWebsiteAddonRenewals() { - await this.userService.processDueWebsiteAddonRenewals().catch((reason) => { + try { + await this.userService.processDueWebsiteAddonRenewals() + await this.userService.processDueSessionReplayAddonRenewals() + } catch (reason) { this.logger.error( `[CRON WORKER](processWebsiteAddonRenewals) Error occured: ${reason}`, ) - }) + } } @Cron(CronExpression.EVERY_2_HOURS) @@ -1473,6 +1479,9 @@ export class TaskManagerService { await this.userService.clearWebsiteAddonsForCancelledSubscription( user.id, ) + await this.userService.clearSessionReplayAddonsForCancelledSubscription( + user.id, + ) await this.userService.update(user.id, { cancellationEffectiveDate: null, planCode: PlanCode.none, @@ -2098,6 +2107,27 @@ export class TaskManagerService { await this.userService.deleteRefreshTokensWhere(where) } + @Cron(CronExpression.EVERY_DAY_AT_1AM) + async cleanupExpiredSessionReplays() { + if (!isPrimaryNode()) { + return + } + + try { + const deleted = await this.analyticsService.cleanupExpiredSessionReplays() + + if (deleted > 0) { + this.logger.log( + `[CRON WORKER](cleanupExpiredSessionReplays) Deleted ${deleted} expired replay chunks`, + ) + } + } catch (reason) { + this.logger.error( + `[CRON WORKER](cleanupExpiredSessionReplays) Error occured: ${reason}`, + ) + } + } + // Verify managed reverse proxy domains: resolve CNAME -> probe TLS to advance // the row's status (waiting -> issuing -> live). @Cron(CronExpression.EVERY_MINUTE) diff --git a/backend/apps/cloud/src/user/dto/update-website-addon.dto.ts b/backend/apps/cloud/src/user/dto/update-website-addon.dto.ts index 92fe24dd1..9331adfd1 100644 --- a/backend/apps/cloud/src/user/dto/update-website-addon.dto.ts +++ b/backend/apps/cloud/src/user/dto/update-website-addon.dto.ts @@ -14,3 +14,15 @@ export class UpdateWebsiteAddonDTO { @IsIn([BillingFrequency.Monthly, BillingFrequency.Yearly]) billingInterval: BillingFrequency } + +export class UpdateSessionReplayAddonDTO { + @ApiProperty({ example: 5000, minimum: 0, maximum: 100000 }) + @IsInt() + @Min(0) + @Max(100000) + quantity: number + + @ApiProperty({ example: BillingFrequency.Monthly, enum: BillingFrequency }) + @IsIn([BillingFrequency.Monthly, BillingFrequency.Yearly]) + billingInterval: BillingFrequency +} diff --git a/backend/apps/cloud/src/user/entities/user-addon.entity.ts b/backend/apps/cloud/src/user/entities/user-addon.entity.ts index a52a7157b..4836a1577 100644 --- a/backend/apps/cloud/src/user/entities/user-addon.entity.ts +++ b/backend/apps/cloud/src/user/entities/user-addon.entity.ts @@ -13,6 +13,7 @@ import { User, BillingFrequency } from './user.entity' export enum UserAddonCode { websites = 'websites', + sessionReplays = 'session_replays', } export enum UserAddonStatus { diff --git a/backend/apps/cloud/src/user/entities/user.entity.ts b/backend/apps/cloud/src/user/entities/user.entity.ts index 18925cbba..ef6d1cfdf 100644 --- a/backend/apps/cloud/src/user/entities/user.entity.ts +++ b/backend/apps/cloud/src/user/entities/user.entity.ts @@ -81,7 +81,7 @@ const PLAN_TYPE_ENTITLEMENTS = { teamMembers: 25, organisations: 10, apiRateLimitPerHour: 6000, - sessionReplaysIncluded: 50000, + sessionReplaysIncluded: 'byEventTier', }, [PlanType.enterprise]: { websites: 'custom', @@ -92,6 +92,22 @@ const PLAN_TYPE_ENTITLEMENTS = { }, } as const +const PLUS_SESSION_REPLAY_QUOTA: Partial> = { + [PlanCode.freelancer]: 5000, + [PlanCode['100k']]: 5000, + [PlanCode['200k']]: 10000, + [PlanCode['500k']]: 25000, + [PlanCode['1m']]: 50000, + [PlanCode['2m']]: 100000, + [PlanCode['5m']]: 250000, + [PlanCode['10m']]: 500000, + [PlanCode['15m']]: 750000, + [PlanCode['20m']]: 1000000, + [PlanCode['30m']]: 1500000, + [PlanCode['40m']]: 2000000, + [PlanCode['50m']]: 2500000, +} + const PLAN_TYPE_RANK = { [PlanType.standard]: 1, [PlanType.plus]: 2, @@ -170,6 +186,65 @@ export const getPlanTypeAccountLimitUpdates = ( return updates } +export const getSessionReplayQuota = ( + user?: { + planCode?: PlanCode | null + planType?: PlanType | null + entitlementOverrides?: Record | null + addonOverrides?: Record | null + } | null, +): number | 'custom' => { + const replayOverride = getNumericLimitOverride( + user?.entitlementOverrides, + 'sessionReplaysIncluded', + ) + const replayAddons = + getNumericLimitOverride(user?.addonOverrides, 'sessionReplays') || 0 + + if (typeof replayOverride === 'number') { + return replayOverride + replayAddons + } + + const effectivePlanType = getEffectivePlanType(user) + const entitlements = getPlanTypeEntitlements(effectivePlanType) + + if (entitlements.sessionReplaysIncluded === 'custom') { + return 'custom' + } + + if (entitlements.sessionReplaysIncluded === 'byEventTier') { + return ( + (PLUS_SESSION_REPLAY_QUOTA[user?.planCode || PlanCode.none] || 0) + + replayAddons + ) + } + + return entitlements.sessionReplaysIncluded + replayAddons +} + +export const getSessionReplayRetentionEntitlement = ( + user?: { + planCode?: PlanCode | null + planType?: PlanType | null + entitlementOverrides?: Record | null + } | null, +): number => { + const override = getNumericLimitOverride( + user?.entitlementOverrides, + 'sessionReplayRetentionDays', + ) + + if (typeof override === 'number') { + return override + } + + if (user?.planType === PlanType.enterprise) { + return 1825 + } + + return getEffectivePlanType(user) === PlanType.enterprise ? 1825 : 30 +} + const planTypeHasFeature = ( planType: PlanType | null | undefined, feature: PlanFeatureCode, diff --git a/backend/apps/cloud/src/user/interfaces/usage-info.ts b/backend/apps/cloud/src/user/interfaces/usage-info.ts index b7446ae85..080fe26d4 100644 --- a/backend/apps/cloud/src/user/interfaces/usage-info.ts +++ b/backend/apps/cloud/src/user/interfaces/usage-info.ts @@ -15,5 +15,6 @@ export interface IUsageInfoBreakdown extends IUsageInfoRedis { export interface IUsageInfo extends IUsageInfoBreakdown { projects: number + sessionReplays: number last30Days: IUsageInfoBreakdown } diff --git a/backend/apps/cloud/src/user/user.controller.ts b/backend/apps/cloud/src/user/user.controller.ts index 41a7f9c07..26e6eccf1 100644 --- a/backend/apps/cloud/src/user/user.controller.ts +++ b/backend/apps/cloud/src/user/user.controller.ts @@ -65,7 +65,10 @@ import { AppLoggerService } from '../logger/logger.service' import { DeleteSelfDTO } from './dto/delete-self.dto' import { CancelSubscriptionDTO } from './dto/cancel-subscription.dto' import { CreateFeedbackDTO } from './dto/create-feedback.dto' -import { UpdateWebsiteAddonDTO } from './dto/update-website-addon.dto' +import { + UpdateSessionReplayAddonDTO, + UpdateWebsiteAddonDTO, +} from './dto/update-website-addon.dto' import { checkRateLimit, getIPDetails, getIPFromHeaders } from '../common/utils' import { IUsageInfo, @@ -142,7 +145,10 @@ export class UserController { const sanitizedUser = this.userService.omitSensitiveData( user, - ) as Partial & { websiteAddon?: unknown } + ) as Partial & { + websiteAddon?: unknown + sessionReplayAddon?: unknown + } sanitizedUser.sharedProjects = sharedProjects sanitizedUser.organisationMemberships = organisationMemberships @@ -150,6 +156,8 @@ export class UserController { uid, user, ) + sanitizedUser.sessionReplayAddon = + await this.userService.getSessionReplayAddonSummary(uid, user) return { user: sanitizedUser, @@ -643,7 +651,9 @@ export class UserController { ) const url = `${isDevelopment ? request.headers.origin : PRODUCTION_ORIGIN}/verify/${token.id}` - await this.userService.update(id, { emailRequests: 1 + user.emailRequests }) + await this.userService.update(id, { + emailRequests: 1 + user.emailRequests, + }) await this.mailerService.sendEmail(user.email, LetterTemplate.SignUp, { url, }) @@ -837,15 +847,18 @@ export class UserController { @Get('usageinfo') async getUsageInfo(@CurrentUserId() uid: string): Promise { - const [rawInfo, rawLast30DaysInfo, projects] = await Promise.all([ - this.projectService.getRedisUsageInfo(uid), - this.projectService.getRedisUsageInfo(uid, 'last30Days'), - this.projectService.countByAdminId(uid), - ]) + const [rawInfo, rawLast30DaysInfo, projects, sessionReplays] = + await Promise.all([ + this.projectService.getRedisUsageInfo(uid), + this.projectService.getRedisUsageInfo(uid, 'last30Days'), + this.projectService.countByAdminId(uid), + this.projectService.getMonthlySessionReplayUsage(uid), + ]) return { ...formatUsageInfo(rawInfo), projects, + sessionReplays, last30Days: formatUsageInfo(rawLast30DaysInfo), } } @@ -923,6 +936,39 @@ export class UserController { return user } + @ApiBearerAuth() + @Post('addons/session-replays/preview') + async previewSessionReplayAddon( + @CurrentUserId() id: string, + @Body() body: UpdateSessionReplayAddonDTO, + ): Promise { + this.logger.log({ body, id }, 'POST /addons/session-replays/preview') + + return this.userService.previewSessionReplayAddon( + id, + body.quantity, + body.billingInterval, + ) + } + + @ApiBearerAuth() + @Put('addons/session-replays') + async updateSessionReplayAddon( + @CurrentUserId() id: string, + @Body() body: UpdateSessionReplayAddonDTO, + ): Promise> { + this.logger.log({ body, id }, 'PUT /addons/session-replays') + + const user = await this.userService.updateSessionReplayAddon( + id, + body.quantity, + body.billingInterval, + ) + await this.projectService.clearProjectsRedisCache(id) + + return user + } + @ApiBearerAuth() @Post('subscription/cancel') async cancelSubscription( diff --git a/backend/apps/cloud/src/user/user.service.ts b/backend/apps/cloud/src/user/user.service.ts index 1947c91dc..e6851458c 100644 --- a/backend/apps/cloud/src/user/user.service.ts +++ b/backend/apps/cloud/src/user/user.service.ts @@ -37,6 +37,7 @@ import { getEffectivePlanType, getPlanTypeEntitlements, getPlanTypeAccountLimitUpdates, + getSessionReplayQuota, DEFAULT_MAX_PROJECTS, } from './entities/user.entity' import { UserProfileDTO } from './dto/user.dto' @@ -129,6 +130,13 @@ const WEBSITE_ADDON_MONTHLY_PRICE: Record = { EUR: 7, GBP: 6, } +const SESSION_REPLAY_ADDON_BUNDLE_SIZE = 5000 +const SESSION_REPLAY_ADDON_MAX_QUANTITY = 100000 +const SESSION_REPLAY_ADDON_MONTHLY_PRICE: Record = { + USD: 19, + EUR: 17, + GBP: 15, +} const WEBSITE_ADDON_CURRENCIES = ['USD', 'EUR', 'GBP'] type WebsiteAddonChangeType = @@ -161,6 +169,27 @@ interface WebsiteAddonPreview { status: UserAddonStatus | null } +interface SessionReplayAddonPreview { + code: UserAddonCode.sessionReplays + quantity: number + currentQuantity: number + pendingQuantity: number | null + billingInterval: BillingFrequency + currentBillingInterval: BillingFrequency | null + pendingBillingInterval: BillingFrequency | null + currency: string + dueNow: number + recurringAmount: number + nextChargeDate: string | null + effectiveDate: string | null + includedSessionReplays: number | 'custom' + totalSessionReplays: number | 'custom' + activeExtraSessionReplays: number + changeType: WebsiteAddonChangeType + isLegacy: boolean + status: UserAddonStatus | null +} + interface WebsiteAddonSummary { code: UserAddonCode.websites quantity: number @@ -176,6 +205,21 @@ interface WebsiteAddonSummary { failedChargeAttempts: number } +interface SessionReplayAddonSummary { + code: UserAddonCode.sessionReplays + quantity: number + pendingQuantity: number | null + billingInterval: BillingFrequency | null + pendingBillingInterval: BillingFrequency | null + currency: string | null + status: UserAddonStatus | 'legacy' | null + periodEnd: string | null + nextChargeDate: string | null + recurringAmount: number | null + isLegacy: boolean + failedChargeAttempts: number +} + type CdnFileResponse = | string | { @@ -301,6 +345,10 @@ export class UserService { ) } + private getSessionReplayAddonOverrideQuantity(user: Partial): number { + return this.getNumericOverride(user?.addonOverrides, 'sessionReplays') || 0 + } + private getMoneyValue(value?: string | null): number | null { if (value === null || value === undefined) { return null @@ -336,13 +384,21 @@ export class UserService { private async getLockedWebsiteAddon( manager: EntityManager, userId: string, + ): Promise { + return this.getLockedAddon(manager, userId, UserAddonCode.websites) + } + + private async getLockedAddon( + manager: EntityManager, + userId: string, + code: UserAddonCode, ): Promise { return manager .getRepository(UserAddon) .createQueryBuilder('addon') .setLock('pessimistic_write') .where('addon.userId = :userId', { userId }) - .andWhere('addon.code = :code', { code: UserAddonCode.websites }) + .andWhere('addon.code = :code', { code }) .getOne() } @@ -369,6 +425,26 @@ export class UserService { ) } + private getIncludedSessionReplayLimit( + user: Partial, + ): number | 'custom' { + const replayOverride = this.getNumericOverride( + user?.entitlementOverrides, + 'sessionReplaysIncluded', + ) + + if (typeof replayOverride === 'number') { + return replayOverride + } + + const addonQuantity = this.getSessionReplayAddonOverrideQuantity(user) + const totalQuota = getSessionReplayQuota(user) + + return totalQuota === 'custom' + ? totalQuota + : Math.max(0, totalQuota - addonQuantity) + } + private getWebsiteAddonCurrency(user: User): string { return typeof user.tierCurrency === 'string' && WEBSITE_ADDON_CURRENCIES.includes(user.tierCurrency) @@ -394,6 +470,21 @@ export class UserService { return this.roundMoney(bundles * monthlyPrice * intervalMultiplier) } + private getSessionReplayAddonAmount( + quantity: number, + billingInterval: BillingFrequency, + currency: string, + ): number { + const monthlyPrice = + SESSION_REPLAY_ADDON_MONTHLY_PRICE[currency] || + SESSION_REPLAY_ADDON_MONTHLY_PRICE.USD + const bundles = quantity / SESSION_REPLAY_ADDON_BUNDLE_SIZE + const intervalMultiplier = + billingInterval === BillingFrequency.Yearly ? 10 : 1 + + return this.roundMoney(bundles * monthlyPrice * intervalMultiplier) + } + private getWebsiteAddonPeriodEnd( from: Date, billingInterval: BillingFrequency, @@ -456,6 +547,58 @@ export class UserService { } } + private validateSessionReplayAddonSelection( + quantity: number, + billingInterval: BillingFrequency, + ) { + if ( + !Number.isInteger(quantity) || + quantity < 0 || + quantity > SESSION_REPLAY_ADDON_MAX_QUANTITY || + quantity % SESSION_REPLAY_ADDON_BUNDLE_SIZE !== 0 + ) { + throw new BadRequestException('invalidSessionReplayAddonQuantity') + } + + if ( + ![BillingFrequency.Monthly, BillingFrequency.Yearly].includes( + billingInterval, + ) + ) { + throw new BadRequestException('invalidSessionReplayAddonBillingInterval') + } + } + + private validateSessionReplayAddonEligibility(user: User) { + const isSubscriber = ![ + PlanCode.none, + PlanCode.free, + PlanCode.trial, + ].includes(user.planCode) + const isTrialingPaidPlan = + user.trialEndDate && dayjs.utc(user.trialEndDate).isAfter(dayjs.utc()) + + if (!isSubscriber || !user.subID) { + throw new BadRequestException('sessionReplayAddonRequiresSubscription') + } + + if (isTrialingPaidPlan) { + throw new BadRequestException('sessionReplayAddonUnavailableDuringTrial') + } + + if (user.cancellationEffectiveDate) { + throw new BadRequestException('sessionReplayAddonUnavailableCancelled') + } + + if (user.isAccountBillingSuspended) { + throw new BadRequestException('sessionReplayAddonUnavailableSuspended') + } + + if (getSessionReplayQuota(user) === 'custom') { + throw new BadRequestException('sessionReplayAddonUnavailableCustom') + } + } + private async getWebsiteAddon(userId: string): Promise { return this.userAddonRepository.findOne({ where: { @@ -465,6 +608,17 @@ export class UserService { }) } + private async getSessionReplayAddon( + userId: string, + ): Promise { + return this.userAddonRepository.findOne({ + where: { + userId, + code: UserAddonCode.sessionReplays, + }, + }) + } + private getProratedWebsiteAddonAmount( addon: UserAddon, addedQuantity: number, @@ -493,6 +647,34 @@ export class UserService { return this.roundMoney(fullAmount * Math.min(1, remainingMs / totalMs)) } + private getProratedSessionReplayAddonAmount( + addon: UserAddon, + addedQuantity: number, + currency: string, + ): number { + const fullAmount = this.getSessionReplayAddonAmount( + addedQuantity, + addon.billingInterval, + currency, + ) + + if (!addon.periodStart || !addon.periodEnd) { + return fullAmount + } + + const now = dayjs.utc() + const start = dayjs.utc(addon.periodStart) + const end = dayjs.utc(addon.periodEnd) + const totalMs = end.diff(start) + const remainingMs = end.diff(now) + + if (totalMs <= 0 || remainingMs <= 0) { + return fullAmount + } + + return this.roundMoney(fullAmount * Math.min(1, remainingMs / totalMs)) + } + private buildWebsiteAddonPreview( user: User, addon: UserAddon | null, @@ -626,6 +808,156 @@ export class UserService { return this.buildWebsiteAddonPreview(user, addon, quantity, billingInterval) } + private buildSessionReplayAddonPreview( + user: User, + addon: UserAddon | null, + quantity: number, + billingInterval: BillingFrequency, + ): SessionReplayAddonPreview { + const legacyQuantity = addon + ? 0 + : this.getSessionReplayAddonOverrideQuantity(user) + const isLegacy = legacyQuantity > 0 + const currentQuantity = + addon && addon.status !== UserAddonStatus.cancelled + ? addon.quantity + : legacyQuantity + const currentBillingInterval = + addon && addon.status !== UserAddonStatus.cancelled + ? addon.billingInterval + : null + const currency = addon?.currency || this.getWebsiteAddonCurrency(user) + const includedSessionReplays = this.getIncludedSessionReplayLimit(user) + const existingChargeDate = addon?.nextChargeDate || addon?.periodEnd || null + let changeType: WebsiteAddonChangeType = 'none' + let dueNow = 0 + let effectiveDate: string | null = null + + if (isLegacy) { + return { + code: UserAddonCode.sessionReplays, + quantity: currentQuantity, + currentQuantity, + pendingQuantity: null, + billingInterval, + currentBillingInterval: null, + pendingBillingInterval: null, + currency, + dueNow: 0, + recurringAmount: 0, + nextChargeDate: null, + effectiveDate: null, + includedSessionReplays, + totalSessionReplays: + includedSessionReplays === 'custom' + ? includedSessionReplays + : includedSessionReplays + currentQuantity, + activeExtraSessionReplays: currentQuantity, + changeType, + isLegacy, + status: null, + } + } + + if ( + !addon || + addon.status === UserAddonStatus.cancelled || + (addon.status === UserAddonStatus.past_due && currentQuantity === 0) + ) { + if (quantity > 0) { + changeType = + addon?.status === UserAddonStatus.past_due ? 'reactivate' : 'new' + dueNow = this.getSessionReplayAddonAmount( + quantity, + billingInterval, + currency, + ) + } + } else if (quantity > currentQuantity) { + changeType = 'increase' + dueNow = this.getProratedSessionReplayAddonAmount( + addon, + quantity - currentQuantity, + currency, + ) + if (billingInterval !== addon.billingInterval) { + effectiveDate = this.formatAddonDate(existingChargeDate) + } + } else if (quantity < currentQuantity) { + changeType = quantity === 0 ? 'cancel' : 'decrease' + effectiveDate = this.formatAddonDate(existingChargeDate) + } else if (billingInterval !== addon.billingInterval) { + changeType = 'interval_change' + effectiveDate = this.formatAddonDate(existingChargeDate) + } else if ( + addon.pendingQuantity !== null || + addon.pendingBillingInterval !== null + ) { + changeType = 'none' + } + + const nextChargeDate = + existingChargeDate || + (quantity > 0 + ? this.getWebsiteAddonPeriodEnd(new Date(), billingInterval) + : null) + const displayedExtraSessionReplays = + changeType === 'decrease' || changeType === 'cancel' + ? currentQuantity + : quantity + + return { + code: UserAddonCode.sessionReplays, + quantity, + currentQuantity, + pendingQuantity: addon?.pendingQuantity ?? null, + billingInterval, + currentBillingInterval, + pendingBillingInterval: addon?.pendingBillingInterval ?? null, + currency, + dueNow, + recurringAmount: this.getSessionReplayAddonAmount( + quantity, + billingInterval, + currency, + ), + nextChargeDate: this.formatAddonDate(nextChargeDate), + effectiveDate, + includedSessionReplays, + totalSessionReplays: + includedSessionReplays === 'custom' + ? includedSessionReplays + : includedSessionReplays + displayedExtraSessionReplays, + activeExtraSessionReplays: currentQuantity, + changeType, + isLegacy, + status: addon?.status || null, + } + } + + async previewSessionReplayAddon( + userId: string, + quantity: number, + billingInterval: BillingFrequency, + ): Promise { + this.validateSessionReplayAddonSelection(quantity, billingInterval) + + const user = await this.findOne({ where: { id: userId } }) + if (!user) { + throw new BadRequestException('User not found') + } + + this.validateSessionReplayAddonEligibility(user) + + const addon = await this.getSessionReplayAddon(userId) + return this.buildSessionReplayAddonPreview( + user, + addon, + quantity, + billingInterval, + ) + } + private async syncWebsiteAddonEntitlements( user: User, quantity: number, @@ -670,11 +1002,53 @@ export class UserService { await this.syncWebsiteAddonEntitlements(user, addonQuantity) } - private getPaddleErrorMessage(data: any): string { - if (typeof data?.error === 'string') { - return data.error - } - + private async syncSessionReplayAddonEntitlements( + user: User, + quantity: number, + manager?: EntityManager, + ): Promise { + const addonOverrides = { ...(user.addonOverrides || {}) } + + if (quantity > 0) { + addonOverrides.sessionReplays = quantity + } else { + delete addonOverrides.sessionReplays + } + + const update = { + addonOverrides: Object.keys(addonOverrides).length + ? addonOverrides + : null, + } + + if (manager) { + await manager.getRepository(User).update(user.id, update) + return + } + + await this.update(user.id, update) + } + + async refreshSessionReplayAddonEntitlements(userId: string): Promise { + const user = await this.findOne({ where: { id: userId } }) + if (!user) { + return + } + + const addon = await this.getSessionReplayAddon(userId) + const addonQuantity = + addon && addon.status !== UserAddonStatus.cancelled + ? addon.quantity + : this.getSessionReplayAddonOverrideQuantity(user) + + await this.syncSessionReplayAddonEntitlements(user, addonQuantity) + } + + private getPaddleErrorMessage(data: any): string { + if (typeof data?.error === 'string') { + return data.error + } + if (typeof data?.response?.message === 'string') { return data.response.message } @@ -874,6 +1248,164 @@ export class UserService { } } + private buildSessionReplayAddonChargeKey( + addon: UserAddon, + user: User, + kind: UserAddonChargeKind, + amount: number, + quantity: number, + previousQuantity: number | null, + billingInterval: BillingFrequency, + periodStart: Date | null, + periodEnd: Date | null, + ): string { + return [ + 'session-replay-addon', + user.id, + addon.id, + kind, + quantity, + previousQuantity ?? 'none', + billingInterval, + amount.toFixed(2), + addon.currency, + periodStart ? dayjs.utc(periodStart).toISOString() : 'none', + periodEnd ? dayjs.utc(periodEnd).toISOString() : 'none', + ].join(':') + } + + private async chargeSessionReplayAddon( + addon: UserAddon, + user: User, + kind: UserAddonChargeKind, + amount: number, + quantity: number, + previousQuantity: number | null, + billingInterval: BillingFrequency, + periodStart: Date | null, + periodEnd: Date | null, + manager?: EntityManager, + ): Promise { + const chargeRepository = this.getAddonChargeRepository(manager) + const chargeName = `${quantity.toLocaleString()} additional session replays for Swetrix (${billingInterval})` + const idempotencyKey = this.buildSessionReplayAddonChargeKey( + addon, + user, + kind, + amount, + quantity, + previousQuantity, + billingInterval, + periodStart, + periodEnd, + ) + + if (addon.lastChargeId) { + const lastCharge = await chargeRepository.findOne({ + where: { id: addon.lastChargeId }, + }) + + if ( + lastCharge?.idempotencyKey === idempotencyKey && + lastCharge.status === UserAddonChargeStatus.succeeded + ) { + return lastCharge + } + } + + let charge = await chargeRepository.findOne({ + where: { idempotencyKey }, + }) + + if (charge?.status === UserAddonChargeStatus.succeeded) { + return charge + } + + if (charge?.status === UserAddonChargeStatus.pending) { + throw new BadRequestException('sessionReplayAddonChargeInProgress') + } + + if (charge) { + await chargeRepository.update(charge.id, { + status: UserAddonChargeStatus.pending, + failureReason: null, + }) + } else { + try { + charge = await chargeRepository.save( + chargeRepository.create({ + addonId: addon.id, + userId: user.id, + kind, + status: UserAddonChargeStatus.pending, + quantity, + previousQuantity, + billingInterval, + amount: amount.toFixed(2), + currency: addon.currency, + periodStart, + periodEnd, + idempotencyKey, + }), + ) + } catch (reason) { + if (!this.isDuplicateEntryError(reason)) { + throw reason + } + + charge = await chargeRepository.findOne({ + where: { idempotencyKey }, + }) + + if (charge?.status === UserAddonChargeStatus.succeeded) { + return charge + } + + throw new BadRequestException('sessionReplayAddonChargeInProgress') + } + } + + if (!charge) { + throw new InternalServerErrorException('Unable to create add-on charge') + } + + try { + const response = await this.createPaddleOneOffCharge( + user.subID, + amount, + chargeName, + ) + + const update = { + status: UserAddonChargeStatus.succeeded, + paddleInvoiceId: + typeof response.invoice_id === 'number' ? response.invoice_id : null, + paddleOrderId: response.order_id ? String(response.order_id) : null, + paddleStatus: response.status ? String(response.status) : null, + paddleReceiptUrl: response.receipt_url + ? String(response.receipt_url) + : null, + paddleResponse: response, + failureReason: null, + } + + await chargeRepository.update(charge.id, update) + + return { + ...charge, + ...update, + } as UserAddonCharge + } catch (reason) { + await chargeRepository.update(charge.id, { + status: UserAddonChargeStatus.failed, + failureReason: + reason instanceof Error ? reason.message : 'Paddle charge failed', + }) + + throw reason + } + } + async getWebsiteAddonSummary( userId: string, user?: User, @@ -934,6 +1466,67 @@ export class UserService { } as Partial } + async getSessionReplayAddonSummary( + userId: string, + user?: User, + ): Promise { + const [addon, resolvedUser] = await Promise.all([ + this.getSessionReplayAddon(userId), + user ? Promise.resolve(user) : this.findOne({ where: { id: userId } }), + ]) + + if (addon) { + return { + code: UserAddonCode.sessionReplays, + quantity: addon.quantity, + pendingQuantity: addon.pendingQuantity, + billingInterval: addon.billingInterval, + pendingBillingInterval: addon.pendingBillingInterval, + currency: addon.currency, + status: addon.status, + periodEnd: this.formatAddonDate(addon.periodEnd), + nextChargeDate: this.formatAddonDate(addon.nextChargeDate), + recurringAmount: this.getMoneyValue(addon.recurringAmount), + isLegacy: false, + failedChargeAttempts: addon.failedChargeAttempts, + } + } + + const legacyQuantity = resolvedUser + ? this.getSessionReplayAddonOverrideQuantity(resolvedUser) + : 0 + + if (!legacyQuantity) { + return null + } + + return { + code: UserAddonCode.sessionReplays, + quantity: legacyQuantity, + pendingQuantity: null, + billingInterval: null, + pendingBillingInterval: null, + currency: resolvedUser?.tierCurrency || null, + status: 'legacy', + periodEnd: null, + nextChargeDate: null, + recurringAmount: null, + isLegacy: true, + failedChargeAttempts: 0, + } + } + + async getUserWithAddonSummaries(userId: string): Promise> { + const user = await this.findOne({ where: { id: userId } }) + const sanitizedUser = this.omitSensitiveData(user) + + return { + ...sanitizedUser, + websiteAddon: await this.getWebsiteAddonSummary(userId, user), + sessionReplayAddon: await this.getSessionReplayAddonSummary(userId, user), + } as Partial + } + async updateWebsiteAddon( userId: string, quantity: number, @@ -1081,21 +1674,175 @@ export class UserService { } }) - return this.getUserWithWebsiteAddonSummary(userId) + return this.getUserWithAddonSummaries(userId) } - private async cancelWebsiteAddonNow(addon: UserAddon): Promise { - await this.userAddonRepository.update(addon.id, { - quantity: 0, - pendingQuantity: null, - pendingBillingInterval: null, - recurringAmount: null, - nextChargeDate: null, - status: UserAddonStatus.cancelled, - failedChargeAttempts: 0, - lastChargeFailedAt: null, - cancelledAt: new Date(), - }) + async updateSessionReplayAddon( + userId: string, + quantity: number, + billingInterval: BillingFrequency, + ): Promise> { + this.validateSessionReplayAddonSelection(quantity, billingInterval) + + await this.usersRepository.manager.transaction(async (manager) => { + const user = await this.getLockedUser(manager, userId) + if (!user) { + throw new BadRequestException('User not found') + } + + this.validateSessionReplayAddonEligibility(user) + + const addonRepository = manager.getRepository(UserAddon) + let addon = await this.getLockedAddon( + manager, + userId, + UserAddonCode.sessionReplays, + ) + if (!addon && this.getSessionReplayAddonOverrideQuantity(user) > 0) { + throw new BadRequestException('legacySessionReplayAddon') + } + + const preview = this.buildSessionReplayAddonPreview( + user, + addon, + quantity, + billingInterval, + ) + + if (preview.changeType === 'none') { + if ( + addon && + (addon.pendingQuantity !== null || + addon.pendingBillingInterval !== null) + ) { + await addonRepository.update(addon.id, { + pendingQuantity: null, + pendingBillingInterval: null, + }) + } + + return + } + + const now = new Date() + + if (!addon) { + addon = await addonRepository.save( + addonRepository.create({ + userId, + code: UserAddonCode.sessionReplays, + quantity: 0, + billingInterval, + currency: this.getWebsiteAddonCurrency(user), + status: UserAddonStatus.cancelled, + failedChargeAttempts: 0, + }), + ) + } + + const isInactiveAddon = + addon.status === UserAddonStatus.cancelled || + (addon.status === UserAddonStatus.past_due && addon.quantity === 0) + const currentQuantity = isInactiveAddon ? 0 : addon.quantity + let charge: UserAddonCharge | null = null + + if (preview.dueNow > 0) { + const chargePeriodStart = isInactiveAddon + ? now + : addon.periodStart || now + const chargePeriodEnd = isInactiveAddon + ? this.getWebsiteAddonPeriodEnd(now, billingInterval) + : addon.periodEnd || + this.getWebsiteAddonPeriodEnd(now, addon.billingInterval) + + charge = await this.chargeSessionReplayAddon( + addon, + user, + isInactiveAddon + ? UserAddonChargeKind.initial + : UserAddonChargeKind.prorated, + preview.dueNow, + quantity, + currentQuantity, + isInactiveAddon ? billingInterval : addon.billingInterval, + chargePeriodStart, + chargePeriodEnd, + manager, + ) + } + + if (isInactiveAddon) { + const periodEnd = this.getWebsiteAddonPeriodEnd(now, billingInterval) + const recurringAmount = this.getSessionReplayAddonAmount( + quantity, + billingInterval, + preview.currency, + ) + + await addonRepository.update(addon.id, { + quantity, + pendingQuantity: null, + billingInterval, + pendingBillingInterval: null, + currency: preview.currency, + recurringAmount: recurringAmount.toFixed(2), + periodStart: now, + periodEnd, + nextChargeDate: periodEnd, + status: UserAddonStatus.active, + failedChargeAttempts: 0, + lastChargeFailedAt: null, + cancelledAt: null, + lastChargeId: charge?.id || addon.lastChargeId, + lastChargeAt: charge ? now : addon.lastChargeAt, + }) + await this.syncSessionReplayAddonEntitlements(user, quantity, manager) + } else if (quantity > currentQuantity) { + const activeBillingInterval = addon.billingInterval + const recurringAmount = this.getSessionReplayAddonAmount( + quantity, + activeBillingInterval, + preview.currency, + ) + + await addonRepository.update(addon.id, { + quantity, + pendingQuantity: null, + pendingBillingInterval: + billingInterval !== addon.billingInterval ? billingInterval : null, + recurringAmount: recurringAmount.toFixed(2), + status: UserAddonStatus.active, + failedChargeAttempts: 0, + lastChargeFailedAt: null, + lastChargeId: charge?.id || addon.lastChargeId, + lastChargeAt: charge ? now : addon.lastChargeAt, + }) + await this.syncSessionReplayAddonEntitlements(user, quantity, manager) + } else { + await addonRepository.update(addon.id, { + pendingQuantity: quantity !== currentQuantity ? quantity : null, + pendingBillingInterval: + billingInterval !== addon.billingInterval ? billingInterval : null, + status: UserAddonStatus.active, + }) + } + }) + + return this.getUserWithAddonSummaries(userId) + } + + private async cancelWebsiteAddonNow(addon: UserAddon): Promise { + await this.userAddonRepository.update(addon.id, { + quantity: 0, + pendingQuantity: null, + pendingBillingInterval: null, + recurringAmount: null, + nextChargeDate: null, + status: UserAddonStatus.cancelled, + failedChargeAttempts: 0, + lastChargeFailedAt: null, + cancelledAt: new Date(), + }) const user = addon.user || (await this.findOne({ where: { id: addon.userId } })) @@ -1145,6 +1892,69 @@ export class UserService { } } + private async cancelSessionReplayAddonNow(addon: UserAddon): Promise { + await this.userAddonRepository.update(addon.id, { + quantity: 0, + pendingQuantity: null, + pendingBillingInterval: null, + recurringAmount: null, + nextChargeDate: null, + status: UserAddonStatus.cancelled, + failedChargeAttempts: 0, + lastChargeFailedAt: null, + cancelledAt: new Date(), + }) + + const user = + addon.user || (await this.findOne({ where: { id: addon.userId } })) + if (user) { + await this.syncSessionReplayAddonEntitlements(user, 0) + } + } + + async scheduleSessionReplayAddonCancellation( + userId: string, + effectiveDate?: string | Date | null, + ): Promise { + const addon = await this.getSessionReplayAddon(userId) + if (!addon || addon.status === UserAddonStatus.cancelled) { + return + } + + const effective = effectiveDate ? dayjs.utc(effectiveDate) : null + if (effective && effective.isBefore(dayjs.utc())) { + await this.cancelSessionReplayAddonNow(addon) + return + } + + await this.userAddonRepository.update(addon.id, { + pendingQuantity: 0, + nextChargeDate: + effective && + (!addon.nextChargeDate || effective.isBefore(addon.nextChargeDate)) + ? effective.toDate() + : addon.nextChargeDate, + }) + } + + async clearSessionReplayAddonsForCancelledSubscription( + userId: string, + ): Promise { + const addons = await this.userAddonRepository.find({ + where: { userId, code: UserAddonCode.sessionReplays }, + relations: ['user'], + }) + + await Promise.all( + addons.map((addon) => this.cancelSessionReplayAddonNow(addon)), + ) + + const user = await this.findOne({ where: { id: userId } }) + if (user) { + await this.syncSessionReplayAddonEntitlements(user, 0) + } + } + private async handleWebsiteAddonRenewalFailure( addon: UserAddon, reason: Error, @@ -1276,6 +2086,139 @@ export class UserService { } } + private async handleSessionReplayAddonRenewalFailure( + addon: UserAddon, + reason: Error, + ): Promise { + const failedChargeAttempts = addon.failedChargeAttempts + 1 + const shouldRemoveCapacity = + failedChargeAttempts >= WEBSITE_ADDON_MAX_FAILED_CHARGES + + await this.userAddonRepository.update(addon.id, { + status: UserAddonStatus.past_due, + failedChargeAttempts, + lastChargeFailedAt: new Date(), + nextChargeDate: shouldRemoveCapacity + ? null + : dayjs.utc().add(WEBSITE_ADDON_RETRY_DELAY_HOURS, 'hour').toDate(), + quantity: shouldRemoveCapacity ? 0 : addon.quantity, + recurringAmount: shouldRemoveCapacity ? null : addon.recurringAmount, + pendingQuantity: shouldRemoveCapacity ? null : addon.pendingQuantity, + pendingBillingInterval: shouldRemoveCapacity + ? null + : addon.pendingBillingInterval, + }) + + if (shouldRemoveCapacity) { + const user = + addon.user || (await this.findOne({ where: { id: addon.userId } })) + if (user) { + await this.syncSessionReplayAddonEntitlements(user, 0) + } + } + + console.error('[ERROR] (processSessionReplayAddonRenewal):', reason) + } + + private async processSessionReplayAddonRenewal( + addon: UserAddon, + ): Promise { + const claimStartedAt = new Date() + const dueDate = addon.nextChargeDate + + if (!dueDate || dayjs.utc(dueDate).isAfter(dayjs.utc(claimStartedAt))) { + return + } + + const claimUntil = dayjs + .utc(claimStartedAt) + .add(WEBSITE_ADDON_RENEWAL_CLAIM_MINUTES, 'minute') + .toDate() + const claimResult = await this.userAddonRepository.update( + { + id: addon.id, + code: UserAddonCode.sessionReplays, + status: addon.status, + nextChargeDate: LessThan(claimStartedAt), + }, + { + nextChargeDate: claimUntil, + }, + ) + + if (!claimResult.affected) { + return + } + + const user = addon.user + + if (!user || !user.subID || user.cancellationEffectiveDate) { + await this.cancelSessionReplayAddonNow(addon) + return + } + + const targetQuantity = addon.pendingQuantity ?? addon.quantity + const targetBillingInterval = + addon.pendingBillingInterval || addon.billingInterval + + if (targetQuantity <= 0) { + await this.cancelSessionReplayAddonNow(addon) + return + } + + const periodStart = dueDate + const periodEnd = this.getWebsiteAddonPeriodEnd( + periodStart, + targetBillingInterval, + ) + const amount = this.getSessionReplayAddonAmount( + targetQuantity, + targetBillingInterval, + addon.currency, + ) + + try { + const charge = await this.chargeSessionReplayAddon( + addon, + user, + UserAddonChargeKind.renewal, + amount, + targetQuantity, + addon.quantity, + targetBillingInterval, + periodStart, + periodEnd, + ) + const recurringAmount = this.getSessionReplayAddonAmount( + targetQuantity, + targetBillingInterval, + addon.currency, + ) + + await this.userAddonRepository.update(addon.id, { + quantity: targetQuantity, + pendingQuantity: null, + billingInterval: targetBillingInterval, + pendingBillingInterval: null, + recurringAmount: recurringAmount.toFixed(2), + periodStart, + periodEnd, + nextChargeDate: periodEnd, + status: UserAddonStatus.active, + failedChargeAttempts: 0, + lastChargeFailedAt: null, + lastChargeId: charge.id, + lastChargeAt: new Date(), + }) + await this.syncSessionReplayAddonEntitlements(user, targetQuantity) + } catch (reason) { + await this.handleSessionReplayAddonRenewalFailure( + addon, + reason instanceof Error ? reason : new Error(String(reason)), + ) + } + } + async processDueWebsiteAddonRenewals(): Promise { const dueAddons = await this.userAddonRepository.find({ where: [ @@ -1298,6 +2241,28 @@ export class UserService { ) } + async processDueSessionReplayAddonRenewals(): Promise { + const dueAddons = await this.userAddonRepository.find({ + where: [ + { + code: UserAddonCode.sessionReplays, + status: UserAddonStatus.active, + nextChargeDate: LessThan(new Date()), + }, + { + code: UserAddonCode.sessionReplays, + status: UserAddonStatus.past_due, + nextChargeDate: LessThan(new Date()), + }, + ], + relations: ['user'], + }) + + await Promise.allSettled( + dueAddons.map((addon) => this.processSessionReplayAddonRenewal(addon)), + ) + } + omitSensitiveData(user: Partial): Partial { const maxEventsCount = ACCOUNT_PLANS[user?.planCode]?.monthlyUsageLimit || 0 const effectivePlanType = getEffectivePlanType(user) @@ -1311,15 +2276,13 @@ export class UserService { numberOverride(addonOverrides, 'websites') || numberOverride(addonOverrides, 'additionalWebsites') || 0 + const purchasedSessionReplayAddons = + numberOverride(addonOverrides, 'sessionReplays') || 0 const websiteOverride = numberOverride(entitlementOverrides, 'websites') const apiOverride = numberOverride( entitlementOverrides, 'apiRateLimitPerHour', ) - const replayOverride = numberOverride( - entitlementOverrides, - 'sessionReplaysIncluded', - ) const includedWebsites = websiteOverride ?? (typeof entitlements.websites === 'number' @@ -1330,8 +2293,7 @@ export class UserService { (typeof entitlements.apiRateLimitPerHour === 'number' ? entitlements.apiRateLimitPerHour : user?.maxApiKeyRequestsPerHour) - const sessionReplaysIncluded = - replayOverride ?? entitlements.sessionReplaysIncluded + const sessionReplaysIncluded = getSessionReplayQuota(user) const enhancedUser = { ...user, @@ -1350,6 +2312,7 @@ export class UserService { : user?.maxApiKeyRequestsPerHour, sessionReplaysIncluded, purchasedWebsiteAddons, + purchasedSessionReplayAddons, } return _omit(enhancedUser, [ @@ -1847,6 +2810,7 @@ export class UserService { await this.update(id, updateParams) await this.refreshWebsiteAddonEntitlements(id) + await this.refreshSessionReplayAddonEntitlements(id) } async generatePayLink( diff --git a/backend/apps/cloud/src/webhook/webhook.controller.ts b/backend/apps/cloud/src/webhook/webhook.controller.ts index e6df53c09..a3de3b824 100644 --- a/backend/apps/cloud/src/webhook/webhook.controller.ts +++ b/backend/apps/cloud/src/webhook/webhook.controller.ts @@ -197,6 +197,9 @@ export class WebhookController { await this.userService.update(currentUser.id, updateParams) await this.userService.refreshWebsiteAddonEntitlements(currentUser.id) + await this.userService.refreshSessionReplayAddonEntitlements( + currentUser.id, + ) await this.projectService.clearProjectsRedisCache(currentUser.id) if (body.alert_name === 'subscription_created' && isTrialing) { @@ -239,6 +242,10 @@ export class WebhookController { user.id, cancellationEffectiveDate, ) + await this.userService.scheduleSessionReplayAddonCancellation( + user.id, + cancellationEffectiveDate, + ) } if (user?.email) { diff --git a/backend/knip.jsonc b/backend/knip.jsonc index 096b8195c..1452ee44e 100644 --- a/backend/knip.jsonc +++ b/backend/knip.jsonc @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "ignoreDependencies": ["@faker-js/faker", "@types/ioredis"], + "ignoreDependencies": ["@faker-js/faker", "@types/ioredis", "rrweb"], "ignore": [], "exclude": ["enumMembers"], "entry": ["./apps/cloud/src/main.ts", "./apps/community/src/main.ts"], diff --git a/backend/migrations/clickhouse/2026_06_03_session_replays.js b/backend/migrations/clickhouse/2026_06_03_session_replays.js new file mode 100644 index 000000000..81280424c --- /dev/null +++ b/backend/migrations/clickhouse/2026_06_03_session_replays.js @@ -0,0 +1,25 @@ +const { queriesRunner, dbName } = require('./setup') + +const queries = [ + `CREATE TABLE IF NOT EXISTS ${dbName}.session_replay_chunks + ( + pid FixedString(12), + psid UInt64, + replayId String CODEC(ZSTD(3)), + chunkIndex UInt32, + objectKey String CODEC(ZSTD(3)), + privacyMode LowCardinality(String), + eventCount UInt32, + uncompressedBytes UInt32, + compressedBytes UInt32, + firstEventTimestamp Nullable(UInt64), + lastEventTimestamp Nullable(UInt64), + created DateTime('UTC') CODEC(Delta(4), LZ4), + expiresAt DateTime('UTC') CODEC(Delta(4), LZ4) + ) + ENGINE = ReplacingMergeTree(created) + PARTITION BY toYYYYMM(created) + ORDER BY (pid, psid, replayId, chunkIndex);`, +] + +queriesRunner(queries) diff --git a/backend/migrations/clickhouse/initialise_database.js b/backend/migrations/clickhouse/initialise_database.js index beb10a635..cc079eae6 100644 --- a/backend/migrations/clickhouse/initialise_database.js +++ b/backend/migrations/clickhouse/initialise_database.js @@ -148,6 +148,26 @@ const CLICKHOUSE_INIT_QUERIES = [ ENGINE = ReplacingMergeTree(synced_at) PARTITION BY toYYYYMM(created) ORDER BY (pid, transaction_id);`, + + `CREATE TABLE IF NOT EXISTS ${dbName}.session_replay_chunks + ( + pid FixedString(12), + psid UInt64, + replayId String CODEC(ZSTD(3)), + chunkIndex UInt32, + objectKey String CODEC(ZSTD(3)), + privacyMode LowCardinality(String), + eventCount UInt32, + uncompressedBytes UInt32, + compressedBytes UInt32, + firstEventTimestamp Nullable(UInt64), + lastEventTimestamp Nullable(UInt64), + created DateTime('UTC') CODEC(Delta(4), LZ4), + expiresAt DateTime('UTC') CODEC(Delta(4), LZ4) + ) + ENGINE = ReplacingMergeTree(created) + PARTITION BY toYYYYMM(created) + ORDER BY (pid, psid, replayId, chunkIndex);`, ] const initialiseDatabase = async () => { diff --git a/backend/migrations/mysql/2026_06_03_session_replays.sql b/backend/migrations/mysql/2026_06_03_session_replays.sql new file mode 100644 index 000000000..2d2020855 --- /dev/null +++ b/backend/migrations/mysql/2026_06_03_session_replays.sql @@ -0,0 +1,2 @@ +ALTER TABLE `project` + ADD COLUMN `sessionReplayRetentionDays` int unsigned NOT NULL DEFAULT 30; diff --git a/backend/migrations/mysql/2026_06_04_session_replay_addons.sql b/backend/migrations/mysql/2026_06_04_session_replay_addons.sql new file mode 100644 index 000000000..b2fbc0d73 --- /dev/null +++ b/backend/migrations/mysql/2026_06_04_session_replay_addons.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user_addon` + MODIFY COLUMN `code` enum('websites','session_replays') NOT NULL; diff --git a/backend/package-lock.json b/backend/package-lock.json index aa18c31be..4eaa70d92 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -64,6 +64,8 @@ "php-serialize": "^5.1.3", "reflect-metadata": "^0.2.2", "request-filtering-agent": "^3.2.0", + "rrweb": "2.0.1", + "rrvideo": "2.0.1", "rxjs": "^7.8.2", "sns-payload-validator": "^2.1.0", "socket.io": "^4.8.3", @@ -2214,6 +2216,37 @@ "npm": ">=5.10.0" } }, + "node_modules/@open-tech-world/cli-progress-bar": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@open-tech-world/cli-progress-bar/-/cli-progress-bar-2.0.2.tgz", + "integrity": "sha512-miNmdKNdKp7Lhy295wxnJcWFrbIAoQmypoCynlG8HTQPxsG5dhOpPL5udsZFoW99RxcLAj2JTCaEhrosTzPE3g==", + "deprecated": "Please use @opentf/cli-pbar", + "license": "MIT", + "dependencies": { + "@open-tech-world/es-cli-styles": "^0.3.0", + "@open-tech-world/es-utils": "^0.10.0" + } + }, + "node_modules/@open-tech-world/es-cli-styles": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-tech-world/es-cli-styles/-/es-cli-styles-0.3.0.tgz", + "integrity": "sha512-0SyxBAUkUDkjt83snx1QvPDk6zJJIb/xRbY0daWGG6ONCfx5RITxc0qty2tLmyBNsCM41+V1shtHNeSrEBu8KQ==", + "deprecated": "Please use @opentf/cli-styles", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@open-tech-world/es-utils": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@open-tech-world/es-utils/-/es-utils-0.10.0.tgz", + "integrity": "sha512-JqpxcVk3Go4RAArI3oB18fhIMeeuNSThwmSQM7ScygVv2/AHRAxkkiodtggC7QniJvLs7i5AzL9pILNvAEBYDg==", + "deprecated": "Please use @opentf/utils", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -4132,6 +4165,44 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@rrweb/packer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/packer/-/packer-2.0.1.tgz", + "integrity": "sha512-2U8RkVe+ZK3gTtmKjJKjoh6QL1Q6wIlt3sBTJeCPERLr9e0JIimNrZ1+bog1L30CTVP3ULuDf5Ba7RZO+QNcHg==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.1", + "fflate": "^0.4.4" + } + }, + "node_modules/@rrweb/packer/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, + "node_modules/@rrweb/replay": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/replay/-/replay-2.0.1.tgz", + "integrity": "sha512-UrZONirb8o4t0urg1aJ8aMSWhNHvJ3a2usuVbPJIm/edyFbwTvvY9sg/rrXccyXOntFhhp73R5nCretODRZcCw==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.1", + "rrweb": "^2.0.1" + } + }, + "node_modules/@rrweb/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.1.tgz", + "integrity": "sha512-x8cNaKFxdvS0/5IfKOj1xG8Htnw1fUwqoPqgP61bOaWqZL6t9UtRPpO9ZlMB2z+FiTxg9zlFFL+Da4ZpFuhoiA==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.1.tgz", + "integrity": "sha512-t+b+4rB23/e4EpoGMdPUr6cKtDsFuOb3LxA9IHE8e3A/Xo0nMUfH0bkwqdpUNa/VGDOdiGeG3bhHjuO9zWX54g==", + "license": "MIT" + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -4603,6 +4674,12 @@ "node": ">=10" } }, + "node_modules/@tsconfig/svelte": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-1.0.13.tgz", + "integrity": "sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4663,6 +4740,12 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/ejs": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", @@ -5175,6 +5258,12 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -5581,6 +5670,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8308,7 +8406,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/handlebars": { @@ -9151,7 +9248,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -9422,9 +9518,9 @@ } }, "node_modules/liquidjs": { - "version": "10.25.7", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.7.tgz", - "integrity": "sha512-rPCjJLiD4eDhQjvv964AeXFC+HbeYBbZrd7Z82Q6hqv1lX7G+5w4SJcKLn9CAAAwHI4aS3dTdo083UB79K3pDA==", + "version": "10.27.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.0.tgz", + "integrity": "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==", "license": "MIT", "optional": true, "dependencies": { @@ -9884,6 +9980,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mjml": { "version": "5.0.0-beta.2", "resolved": "https://registry.npmjs.org/mjml/-/mjml-5.0.0-beta.2.tgz", @@ -10559,7 +10661,6 @@ } ], "license": "MIT", - "optional": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -11513,7 +11614,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -11529,6 +11629,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -11567,7 +11711,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -12873,6 +13016,82 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/rrdom": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.1.tgz", + "integrity": "sha512-z8GdQWFmVfa8GpcGkAqfBXseb89z0ciTPSBkID0qr0i6KHCye1MdQ4VoXFf0Ejp3ELe/c7AObu4zsbSbMT+hiw==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.1" + } + }, + "node_modules/rrvideo": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrvideo/-/rrvideo-2.0.1.tgz", + "integrity": "sha512-gDf0oE1nLU4xMqdkm3nvAKWYLzkAIVSXOIL63CZZf75hqAzavEOJu3P0C7778z8yk18d2Yj79SdKmZyHSuc2IA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@open-tech-world/cli-progress-bar": "^2.0.2", + "fs-extra": "^11.1.1", + "minimist": "^1.2.5", + "playwright": "^1.60.0", + "rrweb-player": "^2.0.1" + }, + "bin": { + "rrvideo": "build/cli.js" + } + }, + "node_modules/rrvideo/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/rrweb": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.1.tgz", + "integrity": "sha512-MQYzOtI7aZft9ffDMT6rmRSAj6hO4W6T7iA4eB9Mtcuiv3M7DbnnxDMrcPW0sQXepgYN46YhfSKe75d/mlUHrQ==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.1", + "@rrweb/utils": "^2.0.1", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.0.1", + "rrweb-snapshot": "^2.0.1" + } + }, + "node_modules/rrweb-player": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb-player/-/rrweb-player-2.0.1.tgz", + "integrity": "sha512-v8PVnOy7Q3kXqWriGuzZo4ndGEV06nCMRkH/jSHweJfTLwpLqb2evgMj0S5FwQLsN2X1VM+L2/i7QWGJfgj9zw==", + "license": "MIT", + "dependencies": { + "@rrweb/packer": "^2.0.1", + "@rrweb/replay": "^2.0.1", + "@tsconfig/svelte": "^1.0.0" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.1.tgz", + "integrity": "sha512-H8TA29ct2tpYtr5oaB0N+ksRY9bhEErfAYcKbce4+w8lqEUQChpKZd41HpBJLkp04b5QUfWUg4v6r9Okf8r7vA==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/run-applescript": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", @@ -13393,7 +13612,6 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -14469,7 +14687,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/backend/package.json b/backend/package.json index a5a267262..4216a33a1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "build": "npm run refresh-submodules && node --max-old-space-size=4096 ./node_modules/@nestjs/cli/bin/nest.js build cloud && mv apps/cloud/dist dist && npm run sentry:sourcemaps", "build:no-submodules-refresh": "node --max-old-space-size=4096 ./node_modules/@nestjs/cli/bin/nest.js build cloud && mv apps/cloud/dist dist && npm run sentry:sourcemaps", "postbuild": "cp .env dist/.env", + "postinstall": "node scripts/patch-rrvideo.js", "format": "oxfmt --write apps/community/src apps/cloud/src", "format:check": "oxfmt --check apps/community/src apps/cloud/src", "clickhouse:initialise": "node migrations/clickhouse/initialise_selfhosted.js", @@ -85,6 +86,8 @@ "php-serialize": "^5.1.3", "reflect-metadata": "^0.2.2", "request-filtering-agent": "^3.2.0", + "rrweb": "2.0.1", + "rrvideo": "2.0.1", "rxjs": "^7.8.2", "sns-payload-validator": "^2.1.0", "socket.io": "^4.8.3", diff --git a/backend/scripts/patch-rrvideo.js b/backend/scripts/patch-rrvideo.js new file mode 100644 index 000000000..002cf79f5 --- /dev/null +++ b/backend/scripts/patch-rrvideo.js @@ -0,0 +1,153 @@ +const fs = require('fs') +const path = require('path') + +const packagePath = require.resolve('rrvideo/package.json') +const packageDir = path.dirname(packagePath) +const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')) + +if (packageJson.version !== '2.0.1') { + throw new Error(`Unsupported rrvideo version: ${packageJson.version}`) +} + +const filePath = path.join(packageDir, 'build', 'index.js') +let source = fs.readFileSync(filePath, 'utf8') + +const playerBundle = + "require.resolve('rrweb-player'), '../../dist/rrweb-player.umd.cjs'" +const rrwebBundle = "require.resolve('rrweb'), '../../dist/rrweb.umd.cjs'" + +if (source.includes(playerBundle)) { + source = source.replace(playerBundle, rrwebBundle) +} else if (!source.includes(rrwebBundle)) { + throw new Error('Could not find rrvideo player bundle path') +} + +const defaultResolutionRatio = ' resolutionRatio: 0.8,' +const oneToOneResolutionRatio = ' resolutionRatio: 0.4,' + +if (source.includes(defaultResolutionRatio)) { + source = source.replace(defaultResolutionRatio, oneToOneResolutionRatio) +} else if (!source.includes(oneToOneResolutionRatio)) { + throw new Error('Could not find rrvideo resolution ratio') +} + +const defaultPageStyle = + ' ' +const exportPageStyle = [ + ' ', +].join('\n') + +if (source.includes(defaultPageStyle)) { + source = source.replace(defaultPageStyle, exportPageStyle) +} else if (!source.includes(exportPageStyle)) { + throw new Error('Could not find rrvideo HTML page style') +} + +const defaultBrowserContext = [ + ' const context = yield browser.newContext({', + ' viewport: scaledViewport,', + ' recordVideo: {', + ' dir: defaultVideoDir,', + ' size: scaledViewport,', + ' },', + ' });', + ' const page = yield context.newPage();', + " yield page.goto('about:blank');", +].join('\n') +const networkLockedBrowserContext = [ + ' const context = yield browser.newContext({', + ' viewport: scaledViewport,', + " serviceWorkers: 'block',", + ' recordVideo: {', + ' dir: defaultVideoDir,', + ' size: scaledViewport,', + ' },', + ' });', + " yield context.route('**/*', (route) => {", + ' const url = route.request().url();', + " if (url === 'about:blank' || url.startsWith('data:') || url.startsWith('blob:')) {", + ' return route.continue();', + ' }', + " return route.abort('blockedbyclient');", + ' });', + ' const page = yield context.newPage();', + " yield page.goto('about:blank');", +].join('\n') + +if (source.includes(defaultBrowserContext)) { + source = source.replace(defaultBrowserContext, networkLockedBrowserContext) +} else if (!source.includes(networkLockedBrowserContext)) { + throw new Error('Could not find rrvideo browser context block') +} + +const blockStart = source.indexOf( + ' const userConfig = ${JSON.stringify((config === null', +) +const blockEndMarker = [ + ' } catch (error) {', + " console.error('Error initializing replayer:', error);", + ' window.onReplayFinish();', + ' }', +].join('\n') +const blockEnd = source.indexOf(blockEndMarker, blockStart) + +if (blockStart === -1 || blockEnd === -1) { + throw new Error('Could not find rrvideo HTML player block') +} + +const blockReplacement = [ + ' const userConfig = ${JSON.stringify((config === null || config === void 0 ? void 0 : config.rrwebPlayer) || {})};', + ' try {', + ' const insertStyleRules = Array.isArray(userConfig.insertStyleRules) ? userConfig.insertStyleRules : [];', + ' window.replayer = new rrweb.Replayer(events, {', + ' ...userConfig,', + ' root: document.body,', + ' insertStyleRules: [...insertStyleRules, \'html, body { background-color: #fff; }\'],', + ' });', + ' const resizeWrapper = () => {', + " const wrapper = document.querySelector('.replayer-wrapper');", + ' if (!wrapper) return;', + " wrapper.style.width = String(userConfig.width || window.innerWidth) + 'px';", + " wrapper.style.height = String(userConfig.height || window.innerHeight) + 'px';", + ' };', + ' resizeWrapper();', + " window.replayer.on('finish', () => window.onReplayFinish());", + " window.replayer.on('resize', resizeWrapper);", + ' setTimeout(() => window.replayer.play(), 0);', + ' } catch (error) {', + " console.error('Error initializing replayer:', error);", + ' window.onReplayFinish();', + ' }', +].join('\n') + +const patched = + source.slice(0, blockStart) + + blockReplacement + + source.slice(blockEnd + blockEndMarker.length) + +if (patched === fs.readFileSync(filePath, 'utf8')) { + console.log('rrvideo patch already applied') +} else { + fs.writeFileSync(filePath, patched) + console.log('Patched rrvideo to render with rrweb Replayer') +} diff --git a/docs/content/docs/add-script.mdx b/docs/content/docs/add-script.mdx index 0d666b5b5..48e6bc0e1 100644 --- a/docs/content/docs/add-script.mdx +++ b/docs/content/docs/add-script.mdx @@ -49,6 +49,8 @@ It's important to remember to replace `YOUR_PROJECT_ID` with your actual Project Done. Now you can start using Swetrix on your website. +To record session replays, start the recorder from your site code after initialising Swetrix. The replay recorder is loaded asynchronously only when you preload or start it. See [`startSessionReplay()`](/swetrix-js-reference#startsessionreplay) for setup and privacy options. + @@ -83,6 +85,8 @@ Swetrix.init("YOUR_PROJECT_ID"); Swetrix.trackViews(); ``` +To record session replays, call `Swetrix.startSessionReplay()` after `Swetrix.init()`. The npm package dynamically imports the installed replay recorder only when you preload or start it. See [`startSessionReplay()`](/swetrix-js-reference#startsessionreplay) for setup and privacy options. + diff --git a/docs/content/docs/analytics-dashboard/error-tracking.mdx b/docs/content/docs/analytics-dashboard/error-tracking.mdx index eb3448204..35225d1af 100644 --- a/docs/content/docs/analytics-dashboard/error-tracking.mdx +++ b/docs/content/docs/analytics-dashboard/error-tracking.mdx @@ -67,7 +67,9 @@ Clicking on any error in the list opens the **Error Details** view. This page pr This section lists individual user sessions where this specific error occurred. - You can see the user's country, browser, and operating system. -- Clicking on a session redirects you to the specific **Session Replay** or session details, allowing you to see the user's journey leading up to the crash. +- Clicking on a session opens the session details. If a replay was recorded, click **Watch replay** to see the user's journey leading up to the crash. + +Learn more about replay playback in the [Session Replays](/analytics-dashboard/session-replays) documentation. ### Segmentation & Breakdown diff --git a/docs/content/docs/analytics-dashboard/meta.json b/docs/content/docs/analytics-dashboard/meta.json index 6e613cbf2..771d45e25 100644 --- a/docs/content/docs/analytics-dashboard/meta.json +++ b/docs/content/docs/analytics-dashboard/meta.json @@ -8,6 +8,7 @@ "funnels", "alerts", "profiles-and-sessions", + "session-replays", "error-tracking", "goals", "feature-flags", diff --git a/docs/content/docs/analytics-dashboard/profiles-and-sessions.mdx b/docs/content/docs/analytics-dashboard/profiles-and-sessions.mdx index 159bbadab..f79987f8f 100644 --- a/docs/content/docs/analytics-dashboard/profiles-and-sessions.mdx +++ b/docs/content/docs/analytics-dashboard/profiles-and-sessions.mdx @@ -61,6 +61,7 @@ For each session, you can see: - **Duration**: How long the session lasted. - **Entry & Exit Pages**: Where they started and ended their session. - **Timeline**: A step-by-step log of every pageview and custom event within that session. +- **Session Replay**: If a replay was recorded for the session, use **Watch replay** to open the replay player and review what the visitor saw and did. This "User Journey" view is incredibly powerful for: @@ -73,6 +74,8 @@ While the **Profiles** tab groups activity by user, the **Sessions** tab lists a Use this view when you want to analyze global session trends or spot anomalies across the entire user base, rather than focusing on specific individuals. +Sessions with an available replay show a replay indicator in the list. Open the session details and click **Watch replay** to view the recording, or use the dedicated [Session Replays](/analytics-dashboard/session-replays) tab to browse all recorded replays for the selected date range. + ### Accessing Sessions from Charts You can also quickly access a filtered list of sessions for a specific time period directly from the main Traffic chart. Simply click on any data point on the chart to pull up the sessions that occurred during that exact interval (e.g., a specific day or hour). diff --git a/docs/content/docs/analytics-dashboard/session-replays.mdx b/docs/content/docs/analytics-dashboard/session-replays.mdx new file mode 100644 index 000000000..d8331dd54 --- /dev/null +++ b/docs/content/docs/analytics-dashboard/session-replays.mdx @@ -0,0 +1,99 @@ +--- +title: Session Replays +slug: /analytics-dashboard/session-replays +--- + + + Session replays are exclusively available on **Swetrix Cloud**. They are not currently available + for self-hosted instances. + + +Session Replays let you watch recorded browser sessions alongside the analytics context Swetrix already collects. Use them to debug confusing journeys, reproduce issues, review funnel drop-offs, and understand what happened before an error or conversion. + + + Session replays are only recorded after your website calls + [`startSessionReplay()`](/swetrix-js-reference#startsessionreplay). A regular pageview or custom + event does not automatically create a replay. + + +## Recording replays + +To collect replays, install the Swetrix tracking script as usual, initialise it with your Project ID, and call `startSessionReplay()` when you want recording to begin. + +```javascript +swetrix.init("YOUR_PROJECT_ID", { + preloadSessionReplay: true, +}); + +await swetrix.startSessionReplay({ + privacy: "total", +}); +``` + +The optional `preloadSessionReplay: true` setting preloads the replay recorder. Recording still begins only after `startSessionReplay()` is called. + +See the [tracking script reference](/swetrix-js-reference#startsessionreplay) for all privacy modes, recorder options, and code examples. + +## Replays list + +Open the **Replays** tab in your project dashboard to see recorded sessions for the selected date range. + +Each replay row shows: + +- The profile or anonymous visitor. +- Country, operating system, and browser. +- Replay event count, pageviews, custom events, revenue, and errors. +- Replay start time and duration. +- Expiry date, when retention metadata is available. + +Use dashboard filters to narrow the list by the same dimensions available elsewhere in analytics, such as country, browser, operating system, page, traffic source, campaign, or custom event. + +The row actions let you: + +- **Watch replay**: open the replay player. +- **View session**: open the session details and full event/page timeline. +- **Go to profile**: open the visitor profile when the replay is associated with a profile. + +## Replay player + +The replay player opens in a modal so you can review the recorded session without leaving the dashboard. + +Available controls include: + +- Play, pause, and scrub through the replay timeline. +- Hover the timeline to preview a point in the replay. +- Change playback speed. +- Toggle the replay event timeline. +- Capture the current frame as a PNG screenshot. +- Export the replay as an MP4. +- Enter fullscreen mode. + +If a replay has no events, has expired, or cannot be loaded, the player shows an empty or error state instead of the recording. + +## Session and error context + +Replays are connected to sessions. In the **Sessions** list, sessions with an available replay show a replay indicator with duration and expiry details. Opening that session shows a **Watch replay** button in the session detail panel. + +Error details can also point you toward the sessions that experienced a specific issue. When a replay exists for one of those sessions, use it to see what the visitor did before the error happened. + +## Privacy and retention + +Session replays can capture page state, user interactions, and non-password input values depending +on the privacy mode and masking options you choose. We recommend asking users for explicit consent +before starting replay recording, especially on public websites or pages that may contain personal +data. If a user opts out or withdraws consent, do not call `startSessionReplay()` for that visitor, +or stop the active replay if one is already running. + +Choose the privacy mode in code when you start the replay: + +- `total`: masks all text and inputs, and blocks media/canvas content. This is the default. +- `normal`: masks all inputs. +- `none`: only forces password inputs to stay masked unless you add your own recorder masking options. + +Configure replay retention in **Project Settings > Session replays**. Retention changes apply only to newly recorded replay chunks; existing replay chunks keep the expiry date they were recorded with. + + + Review your privacy policy, consent flow, masking settings, and legal basis before recording + replays on pages that may contain personal, financial, health, authentication, or other sensitive + data. This page is not legal advice, and requirements vary by jurisdiction and use case. + diff --git a/docs/content/docs/script-reference.mdx b/docs/content/docs/script-reference.mdx index d14773305..a092d1084 100644 --- a/docs/content/docs/script-reference.mdx +++ b/docs/content/docs/script-reference.mdx @@ -25,16 +25,18 @@ swetrix.init("YOUR_PROJECT_ID", { respectDNT: false, apiURL: "https://api.swetrix.com/log", profileId: "user-123", + preloadSessionReplay: true, }); ``` -| Name | Description | Default value | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | -| devMode | When set to `true`, localhost events will be sent to server. | `false` | -| disabled | When set to true, the tracking library won't send any data to server.
Useful for development purposes when this value is set based on '.env' var. | `false` | -| respectDNT | By setting this flag to true, we will not collect ANY kind of data about the user with the DNT setting.
This setting is not true by default because our service anonymises all incoming data and does not pass it on to any third parties under any circumstances. | `false` | -| apiURL | If you use Swetrix Cloud, you don't need to change this parameter. If you're self-hosting Swetrix CE, you should put the URL of your API instance here, like `[BASE_URL]/backend/v1/log`. | `'https://api.swetrix.com/log'` | -| profileId | Optional profile ID for long-term user tracking (MAU/DAU). If set, it will be used for all pageviews and events unless overridden per-call. This allows you to track users across sessions and devices. | `undefined` | +| Name | Description | Default value | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | +| devMode | When set to `true`, localhost events will be sent to server. | `false` | +| disabled | When set to true, the tracking library won't send any data to server.
Useful for development purposes when this value is set based on '.env' var. | `false` | +| respectDNT | By setting this flag to true, we will not collect ANY kind of data about the user with the DNT setting.
This setting is not true by default because our service anonymises all incoming data and does not pass it on to any third parties under any circumstances. | `false` | +| apiURL | If you use Swetrix Cloud, you don't need to change this parameter. If you're self-hosting Swetrix CE, you should put the URL of your API instance here, like `[BASE_URL]/backend/v1/log`. | `'https://api.swetrix.com/log'` | +| profileId | Optional profile ID for long-term user tracking (MAU/DAU). If set, it will be used for all pageviews and events unless overridden per-call. This allows you to track users across sessions and devices. | `undefined` | +| preloadSessionReplay | Set to `true` to preload the session replay recorder after `init()`, or pass `{ rrwebUrl: "https://example.com/rrweb.min.js" }` to load it from a custom URL. Recording only starts after you call `startSessionReplay()`. | `undefined` | ## track() @@ -233,6 +235,71 @@ swetrix.trackError({ }); ``` +## startSessionReplay() + +This function starts recording the current browser session so you can watch it later in the [Session Replays](/analytics-dashboard/session-replays) tab. + + + Session replays may capture page content, user interactions, and non-password input values + depending on the privacy mode you choose. We recommend gating `startSessionReplay()` behind your + consent flow where applicable, and not recording sensitive pages unless you have reviewed your + masking settings, consent requirements, and legal basis for collecting this data. + + +Call `startSessionReplay()` after `init()`. If you set `preloadSessionReplay: true` in `init()`, Swetrix preloads the recorder first so starting a replay is faster. If you do not preload it, `startSessionReplay()` loads the recorder when it is called. + +```javascript +swetrix.init("YOUR_PROJECT_ID", { + preloadSessionReplay: true, +}); + +const replay = await swetrix.startSessionReplay({ + privacy: "total", +}); + +await replay.stop(); +``` + +When using the npm package: + +```javascript +import { init, startSessionReplay } from "swetrix"; + +init("YOUR_PROJECT_ID", { + preloadSessionReplay: true, +}); + +const replay = await startSessionReplay({ + privacy: "total", +}); +``` + +For npm installs, Swetrix dynamically imports the installed `rrweb` package only when the recorder is preloaded or started, so it can be split into an async chunk by your bundler. For script-tag installs, Swetrix loads the standalone replay recorder with an async script tag. + +| Name | Description | Default value | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | +| privacy | Controls how much page content is masked. Use `total` to mask all text and inputs and block media/canvas content, `normal` to mask all inputs, or `none` to only force password inputs to stay masked unless you add your own `rrweb` masking options. | `total` | +| rrweb | Advanced recorder options passed to `rrweb.record()`. If you provide an `emit` callback, Swetrix will call it and still upload replay events. | `undefined` | +| flushIntervalMs | How often replay events are uploaded while recording. | `5000` | +| maxEventsPerChunk | The maximum number of replay events to batch before Swetrix uploads a chunk immediately. | `100` | + +The function returns a `Promise` with controls for the active replay: + +```javascript +{ + flush() {}, + stop() {}, +} +``` + +`flush()` uploads any queued replay events immediately. `stop()` stops recording, flushes pending events, and ends the local recorder for this page. + + + Session replay tracking follows the same `disabled`, `respectDNT`, localhost, and + automated-browser checks as regular analytics. On Swetrix Cloud, the project must also have + session replays available on its plan and quota. + + ## getFeatureFlags() Fetches all feature flags for the project. Results are cached for 5 minutes by default. This cache is shared with experiment assignments. diff --git a/docs/content/docs/sitesettings/project-configuration.mdx b/docs/content/docs/sitesettings/project-configuration.mdx index 25fbe8d35..5d9e5efc9 100644 --- a/docs/content/docs/sitesettings/project-configuration.mdx +++ b/docs/content/docs/sitesettings/project-configuration.mdx @@ -51,6 +51,14 @@ Swetrix has built-in bot detection. You can choose how aggressively bot and auto Below the dropdown you'll find a per-project **Blocked traffic** report showing how many requests were filtered out, broken down by classification and country. See the dedicated [Bot Protection](/bot-protection) page for the full list of detection methods, privacy notes, and FAQ. +## Session Replays + +The **Session replays** tab controls how long newly recorded replay chunks are retained for the project. + +Available retention periods depend on your plan. When you change the retention period, the new value applies only to replay chunks recorded after the change. Existing replay chunks keep the expiry date they were recorded with. + +See [Session Replays](/analytics-dashboard/session-replays) for setup, privacy modes, and playback details. + ## Access Control The **Access** tab manages who can see your dashboard and which organisation the project belongs to. diff --git a/packages/tracker-js/README.md b/packages/tracker-js/README.md index 51c28452d..2f2415c37 100644 --- a/packages/tracker-js/README.md +++ b/packages/tracker-js/README.md @@ -65,6 +65,7 @@ init('YOUR_PROJECT_ID', { disabled: false, respectDNT: false, profileId: 'user-123', + preloadSessionReplay: false, }) ``` @@ -75,6 +76,7 @@ init('YOUR_PROJECT_ID', { | `disabled` | When `true`, no data is sent. Useful for dev environments. | `false` | | `respectDNT` | When `true`, disables tracking for users with Do Not Track enabled. | `false` | | `profileId` | Profile ID for long-term user tracking (MAU/DAU). | `undefined` | +| `preloadSessionReplay` | Preload the session replay recorder after `init()`. Recording only starts after `startSessionReplay()`. | `undefined` | ### `trackViews(options?)` @@ -194,6 +196,35 @@ const variantWithFallback = await getExperiment('checkout-redesign-experiment-id clearExperimentsCache() ``` +### `startSessionReplay(options?)` + +Start recording a session replay. Session replays use `total` privacy by default, which masks text and inputs and blocks media/canvas capture unless you explicitly choose another mode. + +If you use the npm package, rrweb is dynamically imported from your installed dependencies only when the recorder is preloaded or started. If you use the CDN/script-tag build, the standalone replay recorder is loaded with an async script tag. + +```javascript +const replay = await startSessionReplay({ + privacy: 'total', + sampleRate: 0.25, + maxDurationMs: 10 * 60 * 1000, + idleTimeoutMs: 2 * 60 * 1000, +}) + +// Stop or flush manually when needed +await replay.flush() +await replay.stop() +``` + +| Option | Description | Default | +|---|---|---| +| `privacy` | Privacy mode: `total`, `normal`, or `none`. | `'total'` | +| `sampleRate` | Fraction of sessions to record (`0` to `1`). | `1` | +| `maxDurationMs` | Stop recording after this duration. | `undefined` | +| `idleTimeoutMs` | Stop recording after this much visitor inactivity. | `undefined` | +| `flushIntervalMs` | Upload buffered replay events at this interval. | `5000` | +| `maxEventsPerChunk` | Upload once this many events are buffered. | `100` | +| `rrweb` | Additional rrweb record options. | `undefined` | + ### Session & Profile IDs ```javascript diff --git a/packages/tracker-js/jest.config.js b/packages/tracker-js/jest.config.js index b785309f4..3e34bec33 100644 --- a/packages/tracker-js/jest.config.js +++ b/packages/tracker-js/jest.config.js @@ -2,6 +2,7 @@ export default { preset: 'ts-jest', testEnvironment: '/tests/jsdomEnvironment.ts', extensionsToTreatAsEsm: ['.ts'], + watchman: false, moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, @@ -10,6 +11,7 @@ export default { 'ts-jest', { useESM: true, + tsconfig: 'tsconfig.test.json', }, ], }, diff --git a/packages/tracker-js/package-lock.json b/packages/tracker-js/package-lock.json index 0806e8f3c..cd566bc12 100644 --- a/packages/tracker-js/package-lock.json +++ b/packages/tracker-js/package-lock.json @@ -8,6 +8,9 @@ "name": "swetrix", "version": "4.2.0", "license": "MIT", + "dependencies": { + "rrweb": "2.0.1" + }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-node-resolve": "^16.0.3", @@ -1722,6 +1725,18 @@ "win32" ] }, + "node_modules/@rrweb/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.1.tgz", + "integrity": "sha512-x8cNaKFxdvS0/5IfKOj1xG8Htnw1fUwqoPqgP61bOaWqZL6t9UtRPpO9ZlMB2z+FiTxg9zlFFL+Da4ZpFuhoiA==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.1.tgz", + "integrity": "sha512-t+b+4rB23/e4EpoGMdPUr6cKtDsFuOb3LxA9IHE8e3A/Xo0nMUfH0bkwqdpUNa/VGDOdiGeG3bhHjuO9zWX54g==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -1805,6 +1820,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2174,6 +2195,12 @@ "win32" ] }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -2369,6 +2396,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.20", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", @@ -4333,12 +4369,36 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -4592,7 +4652,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -4630,6 +4689,34 @@ "node": ">=8" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/pretty-format": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", @@ -4891,6 +4978,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrdom": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.1.tgz", + "integrity": "sha512-z8GdQWFmVfa8GpcGkAqfBXseb89z0ciTPSBkID0qr0i6KHCye1MdQ4VoXFf0Ejp3ELe/c7AObu4zsbSbMT+hiw==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.1" + } + }, + "node_modules/rrweb": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.1.tgz", + "integrity": "sha512-MQYzOtI7aZft9ffDMT6rmRSAj6hO4W6T7iA4eB9Mtcuiv3M7DbnnxDMrcPW0sQXepgYN46YhfSKe75d/mlUHrQ==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.1", + "@rrweb/utils": "^2.0.1", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.0.1", + "rrweb-snapshot": "^2.0.1" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -4898,6 +5010,15 @@ "dev": true, "license": "MIT" }, + "node_modules/rrweb-snapshot": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.1.tgz", + "integrity": "sha512-H8TA29ct2tpYtr5oaB0N+ksRY9bhEErfAYcKbce4+w8lqEUQChpKZd41HpBJLkp04b5QUfWUg4v6r9Okf8r7vA==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4997,6 +5118,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", diff --git a/packages/tracker-js/package.json b/packages/tracker-js/package.json index 1d6d40f63..4f9c38b5d 100644 --- a/packages/tracker-js/package.json +++ b/packages/tracker-js/package.json @@ -44,6 +44,9 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage" }, + "dependencies": { + "rrweb": "2.0.1" + }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.2", "@rollup/plugin-node-resolve": "^16.0.3", diff --git a/packages/tracker-js/rollup.config.mjs b/packages/tracker-js/rollup.config.mjs index 59090f8d3..232c36a74 100644 --- a/packages/tracker-js/rollup.config.mjs +++ b/packages/tracker-js/rollup.config.mjs @@ -3,13 +3,31 @@ import commonjs from '@rollup/plugin-commonjs' import typescript from '@rollup/plugin-typescript' import terser from '@rollup/plugin-terser' import pkg from './package.json' with { type: 'json' } +import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' const require = createRequire(import.meta.url) +const rrwebDistFile = 'replaylibrary.min.js' +const rrwebPackageFile = 'rrweb.umd.min.cjs' +const rrwebDistPath = join(dirname(require.resolve('rrweb')), rrwebPackageFile) +const external = ['rrweb'] + +const copyRrweb = () => ({ + name: 'copy-rrweb', + generateBundle() { + this.emitFile({ + type: 'asset', + fileName: rrwebDistFile, + source: readFileSync(rrwebDistPath), + }) + }, +}) export default [ { input: 'src/index.ts', + external, output: [ { file: pkg.main, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true }, @@ -26,6 +44,7 @@ export default [ }, { input: 'src/index.ts', + external, output: [{ file: pkg.browser, format: 'umd', name: 'swetrix', sourcemap: true }], plugins: [ typescript({ @@ -35,6 +54,7 @@ export default [ }), nodeResolve(), commonjs(), + copyRrweb(), terser(), ], }, diff --git a/packages/tracker-js/src/Lib.ts b/packages/tracker-js/src/Lib.ts index 253a0d968..a42bb85a6 100644 --- a/packages/tracker-js/src/Lib.ts +++ b/packages/tracker-js/src/Lib.ts @@ -14,6 +14,33 @@ import { getPath, } from './utils.js' +type RrwebEvent = Record + +type RrwebEmit = (event: RrwebEvent) => void + +interface RrwebRecordOptions { + emit?: RrwebEmit + [key: string]: unknown +} + +interface RrwebGlobal { + record?: (options: RrwebRecordOptions) => (() => void) | undefined + Replayer?: unknown +} + +type RrwebModule = RrwebGlobal & { + default?: RrwebGlobal +} + +type SessionReplayPreloadOption = boolean | { rrwebUrl?: string } + +declare global { + interface Window { + rrweb?: RrwebGlobal + __SWETRIX_RRWEB_LOADING__?: Promise + } +} + export interface LibOptions { /** * When set to `true`, localhost events will be sent to server. @@ -39,6 +66,11 @@ export interface LibOptions { * If set, it will be used for all pageviews and events unless overridden per-call. */ profileId?: string + + /** + * Preload session replay recorder code. Recording only starts after calling startSessionReplay(). + */ + preloadSessionReplay?: SessionReplayPreloadOption } export interface TrackEventOptions { @@ -166,6 +198,26 @@ export interface ErrorActions { stop: () => void } +const SESSION_REPLAY_PRIVACY_VALUES = ['total', 'normal', 'none'] as const + +export type SessionReplayPrivacy = + (typeof SESSION_REPLAY_PRIVACY_VALUES)[number] + +export interface SessionReplayOptions { + privacy?: SessionReplayPrivacy + rrweb?: RrwebRecordOptions + flushIntervalMs?: number + maxEventsPerChunk?: number + sampleRate?: number + maxDurationMs?: number + idleTimeoutMs?: number +} + +export interface SessionReplayActions { + stop: () => Promise + flush: () => Promise +} + export interface PageData { /** Current URL path. */ path: string @@ -228,8 +280,26 @@ export const defaultActions = { stop() {}, } +export const defaultSessionReplayActions: SessionReplayActions = { + async stop() {}, + async flush() {}, +} + const DEFAULT_API_HOST = 'https://api.swetrix.com/log' const DEFAULT_API_BASE = 'https://api.swetrix.com' +const DEFAULT_RRWEB_FILE = 'replaylibrary.min.js' +const DEFAULT_RRWEB_URL = `https://cdn.jsdelivr.net/npm/swetrix@latest/dist/${DEFAULT_RRWEB_FILE}` +const DEFAULT_SESSION_REPLAY_FLUSH_INTERVAL = 5000 +const DEFAULT_SESSION_REPLAY_MAX_EVENTS = 100 +const DEFAULT_SESSION_REPLAY_PRIVACY: SessionReplayPrivacy = 'total' +const SESSION_REPLAY_ACTIVITY_EVENTS = [ + 'click', + 'keydown', + 'mousedown', + 'mousemove', + 'scroll', + 'touchstart', +] as const // Default cache duration: 5 minutes const DEFAULT_CACHE_DURATION = 5 * 60 * 1000 @@ -242,11 +312,18 @@ export class Lib { private activePage: string | null = null private errorListenerExists = false private cachedData: CachedData | null = null + private rrwebLoader: Promise | null = null + private sessionReplayActions: SessionReplayActions | null = null + private sessionReplayInitPromise: Promise | null = null constructor(private projectID: string, private options?: LibOptions) { this.trackPathChange = this.trackPathChange.bind(this) this.heartbeat = this.heartbeat.bind(this) this.captureError = this.captureError.bind(this) + + if (this.getSessionReplayPreloadOption()) { + void this.preloadSessionReplay().catch(() => undefined) + } } captureError(event: ErrorEvent): void { @@ -370,7 +447,7 @@ export class Lib { } this.pageViewsOptions = options - let interval: NodeJS.Timeout + let interval: ReturnType if (!options?.unique) { interval = setInterval(this.trackPathChange, 2000) @@ -707,6 +784,215 @@ export class Lib { } } + async startSessionReplay( + options: SessionReplayOptions = {}, + ): Promise { + if (this.sessionReplayActions) { + return this.sessionReplayActions + } + + if (this.sessionReplayInitPromise) { + return this.sessionReplayInitPromise + } + + const initPromise = this.initialiseSessionReplay(options) + this.sessionReplayInitPromise = initPromise + + try { + return await initPromise + } finally { + if (this.sessionReplayInitPromise === initPromise) { + this.sessionReplayInitPromise = null + } + } + } + + private async initialiseSessionReplay( + options: SessionReplayOptions, + ): Promise { + if (this.sessionReplayActions) { + return this.sessionReplayActions + } + + if (!this.canTrack()) { + return defaultSessionReplayActions + } + + if (!this.shouldSampleSessionReplay(options.sampleRate)) { + return defaultSessionReplayActions + } + + try { + await this.preloadSessionReplay() + } catch { + return defaultSessionReplayActions + } + + const rrweb = window.rrweb + if (!rrweb?.record) { + return defaultSessionReplayActions + } + + const privacy = this.getSessionReplayPrivacy(options.privacy) + const replayId = this.createReplayId() + const started = await this.sendSessionReplayStart(replayId, privacy) + + if (!started) { + return defaultSessionReplayActions + } + + const flushIntervalMs = + typeof options.flushIntervalMs === 'number' && options.flushIntervalMs > 0 + ? options.flushIntervalMs + : DEFAULT_SESSION_REPLAY_FLUSH_INTERVAL + const maxEventsPerChunk = + typeof options.maxEventsPerChunk === 'number' && + options.maxEventsPerChunk > 0 + ? Math.floor(options.maxEventsPerChunk) + : DEFAULT_SESSION_REPLAY_MAX_EVENTS + const maxDurationMs = + typeof options.maxDurationMs === 'number' && options.maxDurationMs > 0 + ? options.maxDurationMs + : null + const idleTimeoutMs = + typeof options.idleTimeoutMs === 'number' && options.idleTimeoutMs > 0 + ? options.idleTimeoutMs + : null + + let chunkIndex = 0 + let stopped = false + let events: RrwebEvent[] = [] + let flushing = Promise.resolve() + let maxDurationTimer: ReturnType | undefined + let idleTimer: ReturnType | undefined + + const flush = async (useBeacon = false) => { + if (!events.length) return + + const chunk = events + events = [] + const currentChunkIndex = chunkIndex++ + + flushing = flushing + .catch(() => undefined) + .then(() => + this.sendSessionReplayChunk( + replayId, + privacy, + currentChunkIndex, + chunk, + useBeacon, + ), + ) + + await flushing + } + + const userEmit = options.rrweb?.emit + const recordOptions = this.getSessionReplayRecordOptions( + privacy, + options.rrweb, + (event) => { + try { + userEmit?.(event) + } catch {} + + events.push(event) + if (events.length >= maxEventsPerChunk) { + void flush() + } + }, + ) + + const stopRecording = rrweb.record(recordOptions) + const timer = setInterval(() => void flush(), flushIntervalMs) + const flushOnPageExit = () => void flush(true) + const flushOnHidden = () => { + if (document.visibilityState === 'hidden') { + void flush(true) + } + } + const clearIdleTimer = () => { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = undefined + } + } + const stopSessionReplay = async () => { + if (stopped) return + stopped = true + clearInterval(timer) + if (maxDurationTimer) { + clearTimeout(maxDurationTimer) + } + clearIdleTimer() + window.removeEventListener('pagehide', flushOnPageExit) + document.removeEventListener('visibilitychange', flushOnHidden) + SESSION_REPLAY_ACTIVITY_EVENTS.forEach((eventName) => { + window.removeEventListener(eventName, resetIdleTimer) + }) + stopRecording?.() + await flush() + this.sessionReplayActions = null + this.sessionReplayInitPromise = null + } + const resetIdleTimer = () => { + if (!idleTimeoutMs || stopped) return + clearIdleTimer() + idleTimer = setTimeout(() => void stopSessionReplay(), idleTimeoutMs) + } + + window.addEventListener('pagehide', flushOnPageExit) + document.addEventListener('visibilitychange', flushOnHidden) + + if (maxDurationMs) { + maxDurationTimer = setTimeout( + () => void stopSessionReplay(), + maxDurationMs, + ) + } + + if (idleTimeoutMs) { + SESSION_REPLAY_ACTIVITY_EVENTS.forEach((eventName) => { + window.addEventListener(eventName, resetIdleTimer, { passive: true }) + }) + resetIdleTimer() + } + + this.sessionReplayActions = { + stop: stopSessionReplay, + flush: async () => { + await flush() + }, + } + + return this.sessionReplayActions + } + + private shouldSampleSessionReplay(sampleRate?: number): boolean { + if (typeof sampleRate !== 'number') { + return true + } + + if (sampleRate <= 0) { + return false + } + + if (sampleRate >= 1) { + return true + } + + return Math.random() < sampleRate + } + + private getSessionReplayPrivacy(privacy: unknown): SessionReplayPrivacy { + return SESSION_REPLAY_PRIVACY_VALUES.includes( + privacy as SessionReplayPrivacy, + ) + ? (privacy as SessionReplayPrivacy) + : DEFAULT_SESSION_REPLAY_PRIVACY + } + /** * Gets the API base URL (without /log suffix). */ @@ -815,6 +1101,284 @@ export class Lib { return true } + private getSessionReplayUrl(): string { + const replayOption = this.getSessionReplayPreloadOption() + if ( + replayOption && + typeof replayOption === 'object' && + replayOption.rrwebUrl + ) { + return replayOption.rrwebUrl + } + + return this.getDefaultSessionReplayUrl() + } + + private getSessionReplayPreloadOption(): SessionReplayPreloadOption | undefined { + return this.options?.preloadSessionReplay + } + + private getDefaultSessionReplayUrl(): string { + if (!isInBrowser()) { + return DEFAULT_RRWEB_URL + } + + const trackerScript = this.getTrackerScript() + + if (trackerScript?.src) { + const { hostname, pathname } = new URL(trackerScript.src) + if ( + hostname === 'swetrix.org' && + /^\/swetrix(\.min)?\.js$/i.test(pathname) + ) { + return DEFAULT_RRWEB_URL + } + + return new URL(DEFAULT_RRWEB_FILE, trackerScript.src).toString() + } + + return DEFAULT_RRWEB_URL + } + + private getTrackerScript(): HTMLScriptElement | undefined { + const trackerScript = Array.from(document.scripts).find((script) => { + if (!script.src) { + return false + } + + try { + const { pathname } = new URL(script.src) + return /(^|\/)swetrix(\.min)?\.js$/i.test(pathname) + } catch { + return false + } + }) + + return trackerScript + } + + private preloadSessionReplay(): Promise { + if (!isInBrowser()) { + return Promise.resolve() + } + + if (window.rrweb?.record) { + return Promise.resolve() + } + + if (this.rrwebLoader) { + return this.rrwebLoader + } + + if (window.__SWETRIX_RRWEB_LOADING__) { + this.rrwebLoader = window.__SWETRIX_RRWEB_LOADING__ + void this.rrwebLoader.catch(() => { + if (window.__SWETRIX_RRWEB_LOADING__ === this.rrwebLoader) { + delete window.__SWETRIX_RRWEB_LOADING__ + } + this.rrwebLoader = null + }) + return this.rrwebLoader + } + + this.rrwebLoader = this.loadSessionReplayRecorder() + + window.__SWETRIX_RRWEB_LOADING__ = this.rrwebLoader + const loader = this.rrwebLoader + void loader.catch(() => { + if (window.__SWETRIX_RRWEB_LOADING__ === loader) { + delete window.__SWETRIX_RRWEB_LOADING__ + } + if (this.rrwebLoader === loader) { + this.rrwebLoader = null + } + }) + + return this.rrwebLoader + } + + private async loadSessionReplayRecorder(): Promise { + const replayOption = this.getSessionReplayPreloadOption() + const hasCustomReplayUrl = + replayOption && typeof replayOption === 'object' && replayOption.rrwebUrl + + if (hasCustomReplayUrl || this.getTrackerScript()) { + await this.loadSessionReplayScript(this.getSessionReplayUrl()) + return + } + + if (await this.loadSessionReplayPackage()) { + return + } + + await this.loadSessionReplayScript(this.getDefaultSessionReplayUrl()) + } + + private async loadSessionReplayPackage(): Promise { + try { + const rrwebModule = (await import('rrweb')) as RrwebModule + const rrweb = rrwebModule.record ? rrwebModule : rrwebModule.default + + if (!rrweb?.record) { + return false + } + + window.rrweb = rrweb + return true + } catch { + return false + } + } + + private loadSessionReplayScript(url: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement('script') + script.async = true + script.src = url + script.crossOrigin = 'anonymous' + script.onload = () => resolve() + script.onerror = () => reject(new Error('Failed to load rrweb')) + document.head.appendChild(script) + }) + } + + private getSessionReplayRecordOptions( + privacy: unknown, + userOptions: RrwebRecordOptions | undefined, + emit: RrwebEmit, + ): RrwebRecordOptions { + const options: RrwebRecordOptions = { + ...userOptions, + emit, + } + + const maskInputOptions = + typeof options.maskInputOptions === 'object' && + options.maskInputOptions !== null + ? (options.maskInputOptions as Record) + : {} + + const resolvedPrivacy = this.getSessionReplayPrivacy(privacy) + + if (resolvedPrivacy === 'total') { + return { + ...options, + maskAllInputs: true, + maskTextSelector: '*', + blockSelector: this.mergeSelectors( + options.blockSelector, + 'img, picture, video, audio, canvas, svg', + ), + recordCanvas: false, + inlineImages: false, + emit, + } + } + + if (resolvedPrivacy === 'normal') { + return { + ...options, + maskAllInputs: true, + emit, + } + } + + return { + ...options, + maskInputOptions: { + ...maskInputOptions, + password: true, + }, + emit, + } + } + + private mergeSelectors(existing: unknown, required: string): string { + if (typeof existing === 'string' && existing.trim()) { + return `${existing}, ${required}` + } + + return required + } + + private createReplayId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + private async sendSessionReplayStart( + replayId: string, + privacy: SessionReplayPrivacy, + ): Promise { + try { + const apiBase = this.getApiBase() + const response = await fetch(`${apiBase}/log/session-replay/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + pid: this.projectID, + replayId, + privacy, + pg: + this.activePage || + getPath({ + hash: this.pageViewsOptions?.hash, + search: this.pageViewsOptions?.search, + }), + lc: getLocale(), + tz: getTimezone(), + profileId: this.options?.profileId, + }), + }) + + return response.ok + } catch { + return false + } + } + + private async sendSessionReplayChunk( + replayId: string, + privacy: SessionReplayPrivacy, + chunkIndex: number, + events: RrwebEvent[], + useBeacon: boolean, + ): Promise { + const apiBase = this.getApiBase() + const url = `${apiBase}/log/session-replay/chunk` + const payload = JSON.stringify({ + pid: this.projectID, + replayId, + privacy, + chunkIndex, + events, + }) + + if (useBeacon && typeof navigator.sendBeacon === 'function') { + const sent = navigator.sendBeacon( + url, + new Blob([payload], { type: 'application/json' }), + ) + if (sent) return + } + + try { + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + keepalive: useBeacon, + body: payload, + }) + } catch {} + } + private async sendRequest(path: string, body: object): Promise { const host = this.options?.apiURL || DEFAULT_API_HOST await fetch(`${host}/${path}`, { diff --git a/packages/tracker-js/src/index.ts b/packages/tracker-js/src/index.ts index e6a84964a..1b6ae2527 100644 --- a/packages/tracker-js/src/index.ts +++ b/packages/tracker-js/src/index.ts @@ -11,6 +11,9 @@ import { IPageViewPayload, FeatureFlagsOptions, ExperimentOptions, + SessionReplayOptions, + SessionReplayActions, + defaultSessionReplayActions, } from './Lib.js' export let LIB_INSTANCE: Lib | null = null @@ -82,6 +85,16 @@ export function trackErrors(options?: ErrorOptions): ErrorActions { return LIB_INSTANCE.trackErrors(options) } +export function startSessionReplay( + options?: SessionReplayOptions, +): Promise { + if (!LIB_INSTANCE) { + return Promise.resolve(defaultSessionReplayActions) + } + + return LIB_INSTANCE.startSessionReplay(options) +} + /** * This function is used to manually track an error event. * It's useful if you want to track specific errors in your application. diff --git a/packages/tracker-js/src/types/rrweb-shim.d.ts b/packages/tracker-js/src/types/rrweb-shim.d.ts new file mode 100644 index 000000000..d20456a6c --- /dev/null +++ b/packages/tracker-js/src/types/rrweb-shim.d.ts @@ -0,0 +1,11 @@ +declare module 'rrweb' { + export const record: unknown + export const Replayer: unknown + + const rrweb: { + record?: unknown + Replayer?: unknown + } + + export default rrweb +} diff --git a/packages/tracker-js/tests/sessionReplay.test.ts b/packages/tracker-js/tests/sessionReplay.test.ts new file mode 100644 index 000000000..124b052dc --- /dev/null +++ b/packages/tracker-js/tests/sessionReplay.test.ts @@ -0,0 +1,389 @@ +/// + +import { Lib } from '../src/Lib' +import { setLocation } from './testUtils' + +const PROJECT_ID = 'test-project-id' +const RRWEB_URL = 'https://cdn.jsdelivr.net/npm/swetrix@latest/dist/replaylibrary.min.js' +const mockRrwebRecord = jest.fn() + +jest.mock('rrweb', () => ({ + record: mockRrwebRecord, +})) + +const loadTracker = async () => { + jest.resetModules() + return import('../src/index') +} + +const resetReplayGlobals = () => { + delete (window as any).rrweb + delete (window as any).__SWETRIX_RRWEB_LOADING__ + document.head.innerHTML = '' + document.body.innerHTML = '' +} + +const usePackageRrweb = () => { + const stopRecording = jest.fn() + mockRrwebRecord.mockImplementation((recordOptions) => { + ;(mockRrwebRecord as any).options = recordOptions + return stopRecording + }) + + return { + recordOptions: () => (mockRrwebRecord as any).options, + stopRecording, + } +} + +describe('Session replay tracking', () => { + let fetchMock: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + mockRrwebRecord.mockReset() + delete (mockRrwebRecord as any).options + jest.useRealTimers() + resetReplayGlobals() + setLocation({ hostname: 'example.com', pathname: '/checkout' }) + + Object.defineProperty(navigator, 'doNotTrack', { + value: null, + writable: true, + configurable: true, + }) + + fetchMock = jest.fn().mockResolvedValue({ ok: true }) + Object.defineProperty(globalThis, 'fetch', { + value: fetchMock, + writable: true, + configurable: true, + }) + }) + + test('init with preloadSessionReplay loads npm rrweb but does not record', async () => { + const { init } = await loadTracker() + + init(PROJECT_ID, { devMode: true, preloadSessionReplay: true }) + await (window as any).__SWETRIX_RRWEB_LOADING__ + + expect((window as any).rrweb.record).toBe(mockRrwebRecord) + expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() + expect(mockRrwebRecord).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('session replay script uses jsDelivr for the public swetrix.org loader', async () => { + const script = document.createElement('script') + script.src = 'https://swetrix.org/swetrix.js' + document.head.appendChild(script) + + const { init } = await loadTracker() + init(PROJECT_ID, { devMode: true, preloadSessionReplay: true }) + + expect( + document.querySelector( + `script[src="${RRWEB_URL}"]`, + ), + ).toBeTruthy() + expect(mockRrwebRecord).not.toHaveBeenCalled() + }) + + test('preloadSessionReplay can load rrweb from a custom script URL', async () => { + const rrwebUrl = 'https://cdn.example.com/rrweb.min.js' + const { init } = await loadTracker() + + init(PROJECT_ID, { + devMode: true, + preloadSessionReplay: { rrwebUrl }, + }) + + expect( + document.querySelector(`script[src="${rrwebUrl}"]`), + ).toBeTruthy() + expect(mockRrwebRecord).not.toHaveBeenCalled() + }) + + test('startSessionReplay imports npm rrweb, records events, and flushes chunks', async () => { + const { recordOptions } = usePackageRrweb() + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + const actions = await startSessionReplay({ + flushIntervalMs: 60_000, + maxEventsPerChunk: 2, + }) + const options = recordOptions() + expect(options.maskTextSelector).toBe('*') + expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() + + const startCall = fetchMock.mock.calls.find(([url]) => + String(url).includes('/session-replay/start'), + ) + expect(startCall).toBeTruthy() + expect(JSON.parse(startCall![1].body as string)).toEqual( + expect.objectContaining({ privacy: 'total' }), + ) + + options.emit({ type: 2, timestamp: 100 }) + options.emit({ type: 3, timestamp: 200 }) + + await actions.flush() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.swetrix.com/log/session-replay/start', + expect.objectContaining({ method: 'POST' }), + ) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.swetrix.com/log/session-replay/chunk', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"chunkIndex":0'), + }), + ) + + await actions.stop() + }) + + test('concurrent startSessionReplay calls share one recorder', async () => { + const { stopRecording } = usePackageRrweb() + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + const [firstActions, secondActions] = await Promise.all([ + startSessionReplay({ flushIntervalMs: 60_000 }), + startSessionReplay({ flushIntervalMs: 10_000 }), + ]) + + expect(firstActions).toBe(secondActions) + expect(mockRrwebRecord).toHaveBeenCalledTimes(1) + expect( + fetchMock.mock.calls.filter(([url]) => + String(url).includes('/session-replay/start'), + ), + ).toHaveLength(1) + + await firstActions.stop() + expect(stopRecording).toHaveBeenCalledTimes(1) + }) + + test('sampleRate can skip recording before loading rrweb', async () => { + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + const actions = await startSessionReplay({ sampleRate: 0 }) + await actions.flush() + await actions.stop() + + expect(fetchMock).not.toHaveBeenCalled() + expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() + expect(mockRrwebRecord).not.toHaveBeenCalled() + }) + + test('maxDurationMs stops recording and flushes buffered events', async () => { + jest.useFakeTimers() + const { recordOptions, stopRecording } = usePackageRrweb() + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + await startSessionReplay({ + flushIntervalMs: 60_000, + maxDurationMs: 1000, + }) + recordOptions().emit({ type: 2, timestamp: 100 }) + + await jest.advanceTimersByTimeAsync(1000) + await Promise.resolve() + + expect(stopRecording).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.swetrix.com/log/session-replay/chunk', + expect.objectContaining({ + body: expect.stringContaining('"timestamp":100'), + }), + ) + }) + + test('idleTimeoutMs stops after inactivity and resets on activity', async () => { + jest.useFakeTimers() + const { recordOptions, stopRecording } = usePackageRrweb() + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + await startSessionReplay({ + flushIntervalMs: 60_000, + idleTimeoutMs: 1000, + }) + recordOptions().emit({ type: 2, timestamp: 200 }) + + await jest.advanceTimersByTimeAsync(900) + window.dispatchEvent(new Event('mousemove')) + await jest.advanceTimersByTimeAsync(900) + + expect(stopRecording).not.toHaveBeenCalled() + + await jest.advanceTimersByTimeAsync(100) + await Promise.resolve() + + expect(stopRecording).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.swetrix.com/log/session-replay/chunk', + expect.objectContaining({ + body: expect.stringContaining('"timestamp":200'), + }), + ) + }) + + test('privacy modes map to rrweb options and keep internal emit', () => { + const lib = new Lib(PROJECT_ID, { devMode: true }) + const emit = jest.fn() + + const total = (lib as any).getSessionReplayRecordOptions( + 'total', + { blockSelector: '.secret', emit: jest.fn() }, + emit, + ) + expect(total.maskAllInputs).toBe(true) + expect(total.maskTextSelector).toBe('*') + expect(total.blockSelector).toContain('.secret') + expect(total.blockSelector).toContain('img') + expect(total.recordCanvas).toBe(false) + expect(total.inlineImages).toBe(false) + expect(total.emit).toBe(emit) + + const normal = (lib as any).getSessionReplayRecordOptions( + 'normal', + {}, + emit, + ) + expect(normal.maskAllInputs).toBe(true) + expect(normal.emit).toBe(emit) + + const freeLove = (lib as any).getSessionReplayRecordOptions( + 'none', + { maskInputOptions: { email: false } }, + emit, + ) + expect(freeLove.maskInputOptions).toEqual({ + email: false, + password: true, + }) + expect(freeLove.emit).toBe(emit) + }) + + test('invalid privacy values fall back to total privacy', async () => { + const { recordOptions } = usePackageRrweb() + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + const actions = await startSessionReplay({ + privacy: 'totl' as any, + }) + const startCall = fetchMock.mock.calls.find(([url]) => + String(url).includes('/session-replay/start'), + ) + + expect(startCall).toBeTruthy() + expect(JSON.parse(startCall![1].body as string)).toEqual( + expect.objectContaining({ privacy: 'total' }), + ) + expect(recordOptions().maskTextSelector).toBe('*') + + await actions.stop() + }) + + test('script rrweb loader clears failed loads so startSessionReplay can retry', async () => { + const record = jest.fn(() => jest.fn()) + const trackerScript = document.createElement('script') + trackerScript.src = 'https://example.com/swetrix.js' + document.head.appendChild(trackerScript) + const rrwebUrl = 'https://example.com/replaylibrary.min.js' + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + const failedStart = startSessionReplay() + const failedScript = document.querySelector( + `script[src="${rrwebUrl}"]`, + ) + expect(failedScript).toBeTruthy() + failedScript!.dispatchEvent(new Event('error')) + + await failedStart + expect((window as any).__SWETRIX_RRWEB_LOADING__).toBeUndefined() + + const retryStart = startSessionReplay() + const scripts = document.querySelectorAll( + `script[src="${rrwebUrl}"]`, + ) + expect(scripts[1]).toBeTruthy() + expect(scripts[1]).not.toBe(failedScript) + ;(window as any).rrweb = { record } + scripts[1].dispatchEvent(new Event('load')) + + const actions = await retryStart + expect((window as any).rrweb.record).toBe(record) + expect(record).toHaveBeenCalled() + + await actions.stop() + }) + + test('user rrweb emit is composed with Swetrix uploads', async () => { + const { recordOptions } = usePackageRrweb() + const userEmit = jest.fn() + const { init, startSessionReplay } = await loadTracker() + + init(PROJECT_ID, { devMode: true }) + const actions = await startSessionReplay({ + rrweb: { emit: userEmit, maskAllInputs: true }, + }) + const event = { type: 2, timestamp: 300 } + recordOptions().emit(event) + await actions.flush() + + expect(userEmit).toHaveBeenCalledWith(event) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.swetrix.com/log/session-replay/chunk', + expect.objectContaining({ + body: expect.stringContaining('"timestamp":300'), + }), + ) + + await actions.stop() + }) + + test('DNT and disabled tracking return no-op controls without uploading', async () => { + Object.defineProperty(navigator, 'doNotTrack', { + value: '1', + writable: true, + configurable: true, + }) + + const { init, startSessionReplay } = await loadTracker() + init(PROJECT_ID, { devMode: true, respectDNT: true }) + + const actions = await startSessionReplay() + await actions.flush() + await actions.stop() + + expect(fetchMock).not.toHaveBeenCalled() + expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() + expect(mockRrwebRecord).not.toHaveBeenCalled() + + resetReplayGlobals() + mockRrwebRecord.mockReset() + Object.defineProperty(navigator, 'doNotTrack', { + value: null, + writable: true, + configurable: true, + }) + + const disabledModule = await loadTracker() + disabledModule.init(PROJECT_ID, { devMode: true, disabled: true }) + const disabledActions = await disabledModule.startSessionReplay() + await disabledActions.flush() + + expect(fetchMock).not.toHaveBeenCalled() + expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() + expect(mockRrwebRecord).not.toHaveBeenCalled() + }) +}) diff --git a/packages/tracker-js/tsconfig.esnext.json b/packages/tracker-js/tsconfig.esnext.json index de1bca9a9..b9241802a 100644 --- a/packages/tracker-js/tsconfig.esnext.json +++ b/packages/tracker-js/tsconfig.esnext.json @@ -8,7 +8,11 @@ "sourceMap": true, "declaration": true, "outDir": "dist/esnext", - "typeRoots": ["node_modules/@types"] + "baseUrl": ".", + "paths": { + "rrweb": ["src/types/rrweb-shim.d.ts"] + }, + "types": [] }, "include": ["src"] } diff --git a/packages/tracker-js/tsconfig.json b/packages/tracker-js/tsconfig.json index a7e21f182..469d1fc3f 100644 --- a/packages/tracker-js/tsconfig.json +++ b/packages/tracker-js/tsconfig.json @@ -7,7 +7,12 @@ "strict": true, "sourceMap": true, "declaration": false, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "paths": { + "rrweb": ["src/types/rrweb-shim.d.ts"] + }, + "types": [] }, "include": ["src"] } diff --git a/packages/tracker-js/tsconfig.test.json b/packages/tracker-js/tsconfig.test.json new file mode 100644 index 000000000..4e5d5e168 --- /dev/null +++ b/packages/tracker-js/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"] + }, + "include": ["src", "tests"] +} diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 13523244b..ccd458cec 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -983,6 +983,9 @@ interface Session { profileId: string | null isIdentified: 1 | 0 isFirstSession: 1 | 0 + hasReplay?: 1 | 0 + replayDuration?: number | null + replayExpiresAt?: string | null } export interface SessionsResponse { @@ -992,6 +995,27 @@ export interface SessionsResponse { appliedFilters: AnalyticsFilter[] } +type SessionReplayPrivacy = 'total' | 'normal' | 'none' + +export interface SessionReplayListItem extends Session { + replayId: string + privacyMode: SessionReplayPrivacy + chunkCount: number + eventCount: number + replayStart: string + replayCreatedAt: string + lastReplayCreatedAt: string + firstEventTimestamp?: number | string | null + lastEventTimestamp?: number | string | null +} + +export interface SessionReplaysResponse { + replays: SessionReplayListItem[] + take: number + skip: number + appliedFilters: AnalyticsFilter[] +} + export type SessionEventType = 'traffic' | 'performance' | 'error' export async function getSessionsServer( @@ -1030,6 +1054,38 @@ export async function getSessionsServer( ) } +export async function getSessionReplaysServer( + request: Request, + pid: string, + params: AnalyticsParams & { + take?: number + skip?: number + }, +): Promise> { + const queryParams = new URLSearchParams() + queryParams.append('pid', pid) + queryParams.append('period', params.period) + queryParams.append('filters', serializeFiltersForUrl(params.filters)) + queryParams.append('take', String(params.take || 30)) + queryParams.append('skip', String(params.skip || 0)) + if (params.from) queryParams.append('from', params.from) + if (params.to) queryParams.append('to', params.to) + if (params.timezone) queryParams.append('timezone', params.timezone) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch( + request, + `log/session-replays?${queryParams.toString()}`, + { + headers, + }, + ) +} + export interface FunnelSessionsResponse { sessions: Session[] take: number @@ -1126,6 +1182,42 @@ interface PageflowItem { currency?: string } +export interface SessionReplayMetadata { + hasReplay: boolean + replayId: string + privacyMode: SessionReplayPrivacy + chunkCount: number + eventCount: number + replayDuration: number + replayExpiresAt: string +} + +export interface SessionReplayResponse { + replay: SessionReplayMetadata | null + events: Record[] +} + +export interface DeleteSessionReplayResponse { + deleted: boolean + deletedChunks: number +} + +type SessionReplayExportStatus = + | 'queued' + | 'processing' + | 'ready' + | 'failed' + | 'expired' + +export interface SessionReplayExportResponse { + exportId: string + status: SessionReplayExportStatus + progress: number + filename: string + expiresAt: string + error?: string +} + export interface SessionDetailsResponse { details: { psid: string @@ -1164,6 +1256,7 @@ export interface SessionDetailsResponse { } pages?: PageflowItem[] timeBucket?: string + replay?: SessionReplayMetadata | null } export async function getSessionServer( @@ -1193,6 +1286,69 @@ export async function getSessionServer( ) } +export async function getSessionReplayServer( + request: Request, + pid: string, + psid: string, + replayId?: string, + password?: string, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + psid, + }) + + if (replayId) { + queryParams.append('replayId', replayId) + } + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch( + request, + `log/session-replay?${queryParams.toString()}`, + { + headers, + timeoutMs: 30000, + }, + ) +} + +export async function deleteSessionReplayServer( + request: Request, + pid: string, + psid: string, + replayId?: string, + password?: string, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + psid, + }) + + if (replayId) { + queryParams.append('replayId', replayId) + } + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch( + request, + `log/session-replay?${queryParams.toString()}`, + { + method: 'DELETE', + headers, + timeoutMs: 30000, + }, + ) +} + interface SwetrixError { eid: string name: string diff --git a/web/app/components/marketing/FeaturesGrid.tsx b/web/app/components/marketing/FeaturesGrid.tsx index 95c769d98..1f0c8740e 100644 --- a/web/app/components/marketing/FeaturesGrid.tsx +++ b/web/app/components/marketing/FeaturesGrid.tsx @@ -2,45 +2,54 @@ import { BugIcon, CaretUpIcon, CaretDownIcon, - CodeIcon, + ChartLineIcon, CursorClickIcon, + FlagIcon, FlaskIcon, + FunnelIcon, GaugeIcon, MegaphoneIcon, CookieIcon, + PlayIcon, RobotIcon, ShieldCheckIcon, - TerminalWindowIcon, UserListIcon, - UsersThreeIcon, + VideoCameraIcon, + WarningOctagonIcon, } from '@phosphor-icons/react' import { useTranslation } from 'react-i18next' import { Text } from '~/ui/Text' import { cn } from '~/utils/generic' -const FEATURES = [ +const WEB_ANALYTICS_FEATURES = [ { icon: CookieIcon, key: 'privacy', className: 'text-indigo-500' }, + { icon: ChartLineIcon, key: 'traffic', className: 'text-blue-500' }, { icon: CursorClickIcon, key: 'events', className: 'text-purple-500' }, { icon: MegaphoneIcon, key: 'campaigns', className: 'text-blue-500' }, { icon: RobotIcon, key: 'botBlocking', className: 'text-slate-500' }, { icon: GaugeIcon, key: 'perf', className: 'text-amber-500' }, +] as const + +const PRODUCT_ANALYTICS_FEATURES = [ { icon: BugIcon, key: 'errors', className: 'text-red-500' }, { icon: UserListIcon, key: 'sessions', className: 'text-indigo-500' }, - { icon: UsersThreeIcon, key: 'organisations', className: 'text-emerald-500' }, + { icon: VideoCameraIcon, key: 'replays', className: 'text-sky-500' }, + { icon: FunnelIcon, key: 'funnels', className: 'text-emerald-500' }, { icon: FlaskIcon, key: 'experiments', className: 'text-violet-500' }, { icon: ShieldCheckIcon, key: 'captcha', className: 'text-teal-500' }, - { icon: CodeIcon, key: 'opensource', className: 'text-green-500' }, - { icon: TerminalWindowIcon, key: 'devs', className: 'text-cyan-500' }, ] as const +type FeatureConfig = + | (typeof WEB_ANALYTICS_FEATURES)[number] + | (typeof PRODUCT_ANALYTICS_FEATURES)[number] + const DashboardMockup = () => (
- {/* Chart card */} -
+
(
- {/* Events card */} -
+
Events @@ -158,8 +166,7 @@ const DashboardMockup = () => (
- {/* Metrics card */} -
+
{[ { label: 'Unique visitors', value: '125k', up: true }, @@ -189,8 +196,7 @@ const DashboardMockup = () => (
- {/* Top pages card */} -
+
Pages @@ -230,47 +236,275 @@ const DashboardMockup = () => (
) -export const FeaturesGrid = () => { +const ProductAnalyticsMockup = () => ( +
+
+
+
+
+ +
+
+ + Session replay + + + Checkout flow, 02:14 + +
+
+
+ + + Watch + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((item) => ( +
+ ))} +
+
+
+
+
+
+
+
+
+
+ +
+
+ + + Error tracking + +
+
+ {[ + { label: 'TypeError', meta: '18 users', tone: 'bg-red-500' }, + { label: 'Payment failed', meta: '6 users', tone: 'bg-amber-500' }, + { label: 'API timeout', meta: '3 users', tone: 'bg-slate-400' }, + ].map(({ label, meta, tone }) => ( +
+
+
+ + {label} + +
+ + {meta} + +
+ ))} +
+
+ +
+
+
+ + + Experiments + +
+
+ + + 7.8% + +
+
+
+ {[ + { label: 'New onboarding', value: '64%', width: 64 }, + { label: 'Pricing banner', value: '28%', width: 28 }, + { label: 'Fast checkout', value: '12%', width: 12 }, + ].map(({ label, value, width }) => ( +
+
+ + {label} + + + {value} + +
+
+
+
+
+ ))} +
+
+ + + Feature flag rollout synced + +
+
+ +
+
+ + + Funnel health + +
+
+ {[ + { label: 'Visit', value: '100%', width: 100 }, + { label: 'Signup', value: '42%', width: 42 }, + { label: 'Activation', value: '31%', width: 31 }, + ].map(({ label, value, width }) => ( +
+ + {label} + +
+
+
+ + {value} + +
+ ))} +
+
+ + + CAPTCHA passed without puzzles + +
+
+
+) + +const FeatureList = ({ features }: { features: readonly FeatureConfig[] }) => { const { t } = useTranslation('common') return ( -
-
- -
+
+ {features.map(({ icon: Icon, key, className }) => ( +
+
+
- {t('main.featuresAlt.heading')} + {t(`main.featuresAlt.${key}.desc`)} -
- {FEATURES.map(({ icon: Icon, key, className }) => ( -
-
-
- - {t(`main.featuresAlt.${key}.desc`)} - -
- ))} -
+
+ ))} +
+ ) +} + +const FeatureGroup = ({ + headingKey, + descriptionKey, + features, +}: { + headingKey: string + descriptionKey: string + features: readonly FeatureConfig[] +}) => { + const { t } = useTranslation('common') + + return ( +
+ + {t(`main.featuresAlt.${headingKey}`)} + + + {t(`main.featuresAlt.${descriptionKey}`)} + + +
+ ) +} + +export const FeaturesGrid = () => { + const { t } = useTranslation('common') + + return ( +
+
+ + {t('main.featuresAlt.heading')} + +
+ +
+
+ + +
+ +
+ +
diff --git a/web/app/components/pricing/MarketingPricing.tsx b/web/app/components/pricing/MarketingPricing.tsx index 036710d6a..2379f0833 100644 --- a/web/app/components/pricing/MarketingPricing.tsx +++ b/web/app/components/pricing/MarketingPricing.tsx @@ -28,6 +28,8 @@ import Tooltip from '~/ui/Tooltip' import { cn } from '~/utils/generic' import routes from '~/utils/routes' +import SessionReplayPricingTooltip from './SessionReplayPricingTooltip' + interface MarketingPricingProps { metainfo?: Metainfo onSelectPlan?: (selection: MarketingPricingSelection) => void @@ -53,6 +55,7 @@ interface BenefitTooltip { interface Benefit { label: string tooltip?: BenefitTooltip + tooltipType?: 'sessionReplays' } const planCards: PlanTypeCode[] = ['standard', 'plus', 'enterprise'] @@ -177,7 +180,10 @@ const getBenefits = (planType: PlanTypeCode, t: TFunction): Benefit[] => { })}`, }, }, - { label: t('pricing.benefits.sessionReplays') }, + { + label: t('pricing.benefits.sessionReplays'), + tooltipType: 'sessionReplays', + }, { label: t('pricing.benefits.featureFlags') }, { label: t('pricing.benefits.abTesting') }, { label: t('pricing.benefits.twentyXHigherApiRateLimits') }, @@ -235,13 +241,17 @@ const PlanInheritanceRow = ({ return (
{label} @@ -272,6 +282,9 @@ const BenefitRow = ({ > {benefit.label} + {benefit.tooltipType === 'sessionReplays' ? ( + + ) : null} {benefit.tooltip ? ( { + const { t } = useTranslation('common') + + return ( + + + {t('pricing.benefits.tooltips.sessionReplays.description')} + + + {t('pricing.benefits.tooltips.sessionReplays.recording')} + + + {t('pricing.benefits.tooltips.sessionReplays.quota')} + + + {t('pricing.benefits.tooltips.sessionReplays.plans')} + + + {t('common.learnMore')} + + + ) +} + +const SessionReplayPricingTooltip = ({ + className, + ariaLabel, +}: SessionReplayPricingTooltipProps) => { + const { t } = useTranslation('common') + const label = t('pricing.benefits.sessionReplays') + + return ( + } + className={className} + contentClassName='max-w-[calc(100vw-2rem)] sm:max-w-80' + /> + ) +} + +export default SessionReplayPricingTooltip diff --git a/web/app/hooks/useAnalyticsProxy.ts b/web/app/hooks/useAnalyticsProxy.ts index db8985df8..75cc1b35a 100644 --- a/web/app/hooks/useAnalyticsProxy.ts +++ b/web/app/hooks/useAnalyticsProxy.ts @@ -2,6 +2,10 @@ import { useCallback, useRef, useState } from 'react' import type { SessionsResponse, + SessionReplaysResponse, + SessionReplayResponse, + DeleteSessionReplayResponse, + SessionReplayExportResponse, ErrorsResponse, FeatureFlagStats, FeatureFlagProfilesResponse, @@ -102,6 +106,180 @@ export function useSessionsProxy() { return { fetchSessions, data, error, isLoading } } +export function useSessionReplaysProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchSessionReplays = useCallback( + async (projectId: string, params: ClientAnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const result = await postAnalytics({ + action: 'getSessionReplays', + projectId, + params, + }) + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, + [], + ) + + return { fetchSessionReplays, data, error, isLoading } +} + +export function useSessionReplayProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchSessionReplay = useCallback( + async (projectId: string, psid: string, replayId?: string) => { + setIsLoading(true) + setError(null) + + try { + const result = await postAnalytics({ + action: 'getSessionReplay', + projectId, + psid, + replayId, + params: {}, + }) + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + return null + } finally { + setIsLoading(false) + } + }, + [], + ) + + return { fetchSessionReplay, data, error, isLoading } +} + +export function useDeleteSessionReplayProxy() { + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const deleteSessionReplay = useCallback( + async (projectId: string, psid: string, replayId?: string) => { + setIsLoading(true) + setError(null) + + try { + const result = await postAnalytics({ + action: 'deleteSessionReplay', + projectId, + psid, + replayId, + params: {}, + }) + setError(result.error) + return result.data + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + return null + } finally { + setIsLoading(false) + } + }, + [], + ) + + return { deleteSessionReplay, error, isLoading } +} + +async function parseExportProxyResponse( + response: Response, +): Promise { + let result: ProxyResponse + try { + result = + (await response.json()) as ProxyResponse + } catch { + throw new Error( + `Invalid JSON from /api/session-replay-export (status ${response.status})`, + ) + } + + if (!response.ok || !result.data) { + throw new Error( + result.error || `Request failed (status ${response.status})`, + ) + } + + return result.data +} + +export function useSessionReplayExportProxy() { + const startSessionReplayExport = useCallback( + async ( + projectId: string, + psid: string, + replayId?: string, + signal?: AbortSignal, + ) => { + const response = await fetch('/api/session-replay-export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ projectId, psid, replayId }), + }) + + return parseExportProxyResponse(response) + }, + [], + ) + + const getSessionReplayExportStatus = useCallback( + async (projectId: string, exportId: string, signal?: AbortSignal) => { + const query = new URLSearchParams({ projectId, exportId }) + const response = await fetch(`/api/session-replay-export?${query}`, { + signal, + }) + + return parseExportProxyResponse(response) + }, + [], + ) + + const getSessionReplayExportDownloadUrl = useCallback( + (projectId: string, exportId: string) => { + const query = new URLSearchParams({ + action: 'download', + projectId, + exportId, + }) + + return `/api/session-replay-export?${query}` + }, + [], + ) + + return { + startSessionReplayExport, + getSessionReplayExportStatus, + getSessionReplayExportDownloadUrl, + } +} + export function useErrorsProxy() { const [data, setData] = useState(null) const [error, setError] = useState(null) diff --git a/web/app/lib/constants/index.ts b/web/app/lib/constants/index.ts index 92bd4a747..9966f044b 100644 --- a/web/app/lib/constants/index.ts +++ b/web/app/lib/constants/index.ts @@ -361,7 +361,6 @@ export const DISCORD_URL = 'https://discord.gg/ZVK8Tw2E8j' export const STATUSPAGE_URL = 'https://status.swetrix.com/' export const MAIN_URL = 'https://swetrix.com' export const LIVE_DEMO_URL = '/projects/STEzHcB1rALV' -export const BOOK_A_CALL_URL = 'https://cal.com/swetrix/30min' /** The cal.com link slug (without origin) used by the @calcom/embed-react inline widget. */ export const BOOK_A_CALL_CAL_LINK = 'swetrix/30min' export const PERFORMANCE_LIVE_DEMO_URL = @@ -561,6 +560,7 @@ const SELFHOSTED_PROJECT_TABS = { funnels: 'funnels', profiles: 'profiles', sessions: 'sessions', + replays: 'replays', errors: 'errors', goals: 'goals', experiments: 'experiments', @@ -575,6 +575,7 @@ const PRODUCTION_PROJECT_TABS = { profiles: 'profiles', funnels: 'funnels', sessions: 'sessions', + replays: 'replays', errors: 'errors', goals: 'goals', experiments: 'experiments', diff --git a/web/app/lib/models/Project.ts b/web/app/lib/models/Project.ts index 7ffcefb39..557893164 100644 --- a/web/app/lib/models/Project.ts +++ b/web/app/lib/models/Project.ts @@ -85,6 +85,18 @@ export interface SwetrixError { sessions: number } +type SessionReplayPrivacy = 'total' | 'normal' | 'none' + +export interface SessionReplayMetadata { + hasReplay: boolean + replayId: string + privacyMode: SessionReplayPrivacy + chunkCount: number + eventCount: number + replayDuration: number + replayExpiresAt: string +} + export interface Session { psid: string cc: string | null @@ -105,6 +117,9 @@ export interface Session { profileId: string | null isIdentified: 1 | 0 isFirstSession: 1 | 0 + hasReplay?: 1 | 0 + replayDuration?: number | null + replayExpiresAt?: string | null } export interface SessionDetails { @@ -252,6 +267,7 @@ export interface Project { isErrorDataExists: boolean isCaptchaDataExists: boolean featureAccess?: ProjectFeatureAccess + sessionReplayRetentionDays?: number botsProtectionLevel: 'off' | 'basic' | 'strict' role?: Role gscPropertyUri?: string | null diff --git a/web/app/lib/models/Usageinfo.ts b/web/app/lib/models/Usageinfo.ts index 7312a0daa..58b364c62 100644 --- a/web/app/lib/models/Usageinfo.ts +++ b/web/app/lib/models/Usageinfo.ts @@ -12,5 +12,6 @@ interface UsageInfoBreakdown { export interface UsageInfo extends UsageInfoBreakdown { projects: number + sessionReplays: number last30Days: UsageInfoBreakdown } diff --git a/web/app/lib/models/User.ts b/web/app/lib/models/User.ts index 8892e75ac..cec386990 100644 --- a/web/app/lib/models/User.ts +++ b/web/app/lib/models/User.ts @@ -50,6 +50,21 @@ interface WebsiteAddon { failedChargeAttempts: number } +interface SessionReplayAddon { + code: 'session_replays' + quantity: number + pendingQuantity: number | null + billingInterval: 'monthly' | 'yearly' | null + pendingBillingInterval: 'monthly' | 'yearly' | null + currency: 'USD' | 'EUR' | 'GBP' | null + status: 'active' | 'past_due' | 'cancelled' | 'legacy' | null + periodEnd: string | null + nextChargeDate: string | null + recurringAmount: number | null + isLegacy: boolean + failedChargeAttempts: number +} + enum OnboardingStep { LANGUAGE = 'language', WELCOME = 'welcome', @@ -104,7 +119,9 @@ export interface User { maxApiKeyRequestsPerHour: number sessionReplaysIncluded: number | string purchasedWebsiteAddons: number + purchasedSessionReplayAddons: number websiteAddon?: WebsiteAddon | null + sessionReplayAddon?: SessionReplayAddon | null tierCurrency: 'USD' | 'EUR' | 'GBP' | null showLiveVisitorsInTitle: boolean receiveLoginNotifications: boolean diff --git a/web/app/lib/pricing/catalog.ts b/web/app/lib/pricing/catalog.ts index 1d2ff8b60..151a3521d 100644 --- a/web/app/lib/pricing/catalog.ts +++ b/web/app/lib/pricing/catalog.ts @@ -387,49 +387,6 @@ export const PLAN_ENTITLEMENTS = { }, } as const -export const ADDONS = { - websiteBundles: [ - { - code: 'websites_50', - quantity: 50, - labelKey: 'pricing.addons.websiteBundle', - monthly: { USD: 7.5, EUR: 7, GBP: 6 }, - }, - { - code: 'websites_250', - quantity: 250, - labelKey: 'pricing.addons.websiteBundle', - monthly: { USD: 30, EUR: 27, GBP: 24 }, - }, - { - code: 'websites_1000', - quantity: 1000, - labelKey: 'pricing.addons.websiteBundle', - monthly: { USD: 99, EUR: 89, GBP: 79 }, - }, - ], - sessionReplayBundles: [ - { - code: 'replays_5000', - quantity: 5000, - labelKey: 'pricing.addons.sessionReplayBundle', - monthly: { USD: 19, EUR: 17, GBP: 15 }, - }, - { - code: 'replays_25000', - quantity: 25000, - labelKey: 'pricing.addons.sessionReplayBundle', - monthly: { USD: 79, EUR: 69, GBP: 59 }, - }, - { - code: 'replays_100000', - quantity: 100000, - labelKey: 'pricing.addons.sessionReplayBundle', - monthly: { USD: 249, EUR: 209, GBP: 189 }, - }, - ], -} as const - const LEGACY_PLAN_LIMITS = { none: { index: 0, diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index e0351873e..54b67dbcc 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -25,6 +25,7 @@ import { DownloadIcon, BellRingingIcon, GlobeIcon, + VideoCameraIcon, } from '@phosphor-icons/react' import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -69,6 +70,7 @@ import AccessSettings from './tabs/AccessSettings' import DangerZone from './tabs/DangerZone' import General from './tabs/General' import Revenue from './tabs/Revenue' +import SessionReplays from './tabs/SessionReplays' import Shields from './tabs/Shields' import ProjectAlerts from './Alerts/ProjectAlertsView' import NotificationChannels from '~/components/NotificationChannels/NotificationChannels' @@ -364,6 +366,7 @@ const getFormFromProject = (project: Project): Form => ({ brandKeywords: project.brandKeywords?.join(', ') || '', captchaDifficulty: project.captchaDifficulty || 4, captchaDifficultyMode: project.captchaDifficultyMode || 'manual', + sessionReplayRetentionDays: project.sessionReplayRetentionDays || 30, }) const normaliseProjectAutosaveValue = (value: unknown) => @@ -390,7 +393,10 @@ const buildProjectAutosaveFormData = (updates: Partial
) => { return } - if (field === 'captchaDifficulty') { + if ( + field === 'captchaDifficulty' || + field === 'sessionReplayRetentionDays' + ) { formData.set(field, String(value)) return } @@ -434,6 +440,7 @@ const ProjectSettings = () => { websiteUrl?: string transferEmail?: string email?: string + sessionReplayRetentionDays?: string }>({}) const [beenSubmitted, setBeenSubmitted] = useState(false) const [showDelete, setShowDelete] = useState(false) @@ -473,6 +480,7 @@ const ProjectSettings = () => { | 'alerts' | 'channels' | 'revenue' + | 'sessionReplays' | 'emails' | 'people' | 'annotations' @@ -548,6 +556,14 @@ const ProjectSettings = () => { iconColor: 'text-green-500', visible: !isSelfhosted, }, + { + id: 'sessionReplays', + label: t('project.settings.tabs.sessionReplays'), + description: t('project.settings.tabs.sessionReplaysDesc'), + icon: VideoCameraIcon, + iconColor: 'text-violet-500', + visible: !isSelfhosted, + }, { id: 'emails', label: t('project.settings.tabs.emails'), @@ -601,6 +617,17 @@ const ProjectSettings = () => { [t, project?.role], ) + const sessionReplayMaxRetentionDays = useMemo(() => { + const override = user?.entitlementOverrides?.sessionReplayRetentionDays + if (typeof override === 'number') { + return override + } + if (user?.effectivePlanType === 'enterprise') { + return 1825 + } + return 30 + }, [user?.effectivePlanType, user?.entitlementOverrides]) + const activeTab = useMemo(() => { const tab = searchParams.get('tab') as SettingsTab const allowed = new Set(tabs.map((t) => t.id as SettingsTab)) @@ -1453,6 +1480,26 @@ const ProjectSettings = () => { ) : null} + {activeTab === 'sessionReplays' && activeTabConfig ? ( + <> + + + handleFieldAutosave( + { sessionReplayRetentionDays: days }, + 'project.settings.autosave.sessionReplayRetention', + ) + } + /> + + ) : null} {activeTab === 'integrations' && activeTabConfig ? (
diff --git a/web/app/pages/Project/Settings/SettingsSidebar.tsx b/web/app/pages/Project/Settings/SettingsSidebar.tsx index 2e0e31fc3..48437b276 100644 --- a/web/app/pages/Project/Settings/SettingsSidebar.tsx +++ b/web/app/pages/Project/Settings/SettingsSidebar.tsx @@ -213,7 +213,12 @@ const SettingsSidebar = ({ { id: 'data', label: t('project.settings.sidebarGroups.data'), - tabIds: ['annotations', 'revenue', 'import'] as TabId[], + tabIds: [ + 'annotations', + 'revenue', + 'sessionReplays', + 'import', + ] as TabId[], }, { id: 'integrations', diff --git a/web/app/pages/Project/Settings/tabs/SessionReplays.tsx b/web/app/pages/Project/Settings/tabs/SessionReplays.tsx new file mode 100644 index 000000000..65dd403d3 --- /dev/null +++ b/web/app/pages/Project/Settings/tabs/SessionReplays.tsx @@ -0,0 +1,113 @@ +import { LockIcon } from '@phosphor-icons/react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import Button from '~/ui/Button' +import Select from '~/ui/Select' +import { Text } from '~/ui/Text' +import routes from '~/utils/routes' + +const RETENTION_OPTIONS = [30, 90, 365, 1825] as const +type RetentionOption = (typeof RETENTION_OPTIONS)[number] + +interface SessionReplaysProps { + retentionDays: number + maxRetentionDays: number + onRetentionChange: (days: number) => void +} + +const SessionReplays = ({ + retentionDays, + maxRetentionDays, + onRetentionChange, +}: SessionReplaysProps) => { + const { t } = useTranslation('common') + const selectedRetention = RETENTION_OPTIONS.includes(retentionDays as any) + ? retentionDays + : 30 + const retentionItems = useMemo( + () => RETENTION_OPTIONS.map((days) => ({ days })), + [], + ) + const selectedRetentionItem = useMemo( + () => + retentionItems.find((item) => item.days === selectedRetention) || + retentionItems[0], + [retentionItems, selectedRetention], + ) + const hasLockedOptions = useMemo( + () => RETENTION_OPTIONS.some((days) => days > maxRetentionDays), + [maxRetentionDays], + ) + + return ( +
+
+ + {t('project.settings.sessionReplays.retentionTitle')} + + + {t('project.settings.sessionReplays.retentionDescription')} + +
+ +
+ + label={t('project.settings.sessionReplays.retentionLabel')} + title={t( + `project.settings.sessionReplays.retention.${selectedRetentionItem.days}`, + )} + items={retentionItems} + keyExtractor={(item) => String(item.days)} + labelExtractor={(item) => + t(`project.settings.sessionReplays.retention.${item.days}`) + } + iconExtractor={(item) => + item.days > maxRetentionDays ? ( + + ) : null + } + descriptionExtractor={(item) => + item.days > maxRetentionDays + ? t('project.settings.sessionReplays.upgradeRequired') + : null + } + disabledItemExtractor={(item) => + item.days > maxRetentionDays && item.days !== selectedRetention + } + selectedItem={selectedRetentionItem} + onSelect={(item) => { + if ( + item.days > maxRetentionDays || + item.days === selectedRetention + ) { + return + } + + onRetentionChange(item.days) + }} + /> +
+ + + {t('project.settings.sessionReplays.retentionHint')} + + + {hasLockedOptions ? ( +
+ + {t('project.settings.sessionReplays.upgradeTitle')} + + + {t('project.settings.sessionReplays.upgradeDescription')} + + +
+ ) : null} +
+ ) +} + +export default SessionReplays diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index ddaeb7032..41e8f7e46 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -20,6 +20,7 @@ import { FlaskIcon, IdentificationCardIcon, UserListIcon, + MonitorPlayIcon, ToggleRightIcon, MagnifyingGlassIcon, } from '@phosphor-icons/react' @@ -92,6 +93,7 @@ import FunnelsView from '../tabs/Funnels/FunnelsView' import GoalsView from '../tabs/Goals/GoalsView' import PerformanceView from '../tabs/Performance/PerformanceView' import ProfilesView from '../tabs/Profiles/ProfilesView' +import ReplaysView from '../tabs/Replays/ReplaysView' import SessionsView from '../tabs/Sessions/SessionsView' import SEOView from '../tabs/SEO/SEOView' import TrafficView from '../tabs/Traffic/TrafficView' @@ -196,6 +198,7 @@ interface RefreshTriggersContextType { trafficRefreshTrigger: number funnelsRefreshTrigger: number profilesRefreshTrigger: number + replaysRefreshTrigger: number seoRefreshTrigger: number } @@ -264,6 +267,7 @@ const defaultRefreshTriggersContext: RefreshTriggersContextType = { trafficRefreshTrigger: 0, funnelsRefreshTrigger: 0, profilesRefreshTrigger: 0, + replaysRefreshTrigger: 0, seoRefreshTrigger: 0, } @@ -352,6 +356,7 @@ const ViewProjectContent = () => { const [trafficRefreshTrigger, setTrafficRefreshTrigger] = useState(0) const [funnelsRefreshTrigger, setFunnelsRefreshTrigger] = useState(0) const [profilesRefreshTrigger, setProfilesRefreshTrigger] = useState(0) + const [replaysRefreshTrigger, setReplaysRefreshTrigger] = useState(0) const [seoRefreshTrigger, setSeoRefreshTrigger] = useState(0) const [activeChartMetrics] = useState< Record @@ -765,6 +770,11 @@ const ViewProjectContent = () => { label: t('dashboard.sessions'), icon: UserListIcon, }, + { + id: PROJECT_TABS.replays, + label: t('dashboard.replays'), + icon: MonitorPlayIcon, + }, { id: PROJECT_TABS.errors, label: t('dashboard.errors'), @@ -937,6 +947,11 @@ const ViewProjectContent = () => { return } + if (activeTab === PROJECT_TABS.replays) { + setReplaysRefreshTrigger((prev) => prev + 1) + return + } + if (activeTab === PROJECT_TABS.errors) { setErrorsRefreshTrigger((prev) => prev + 1) return @@ -1298,6 +1313,7 @@ const ViewProjectContent = () => { trafficRefreshTrigger, funnelsRefreshTrigger, profilesRefreshTrigger, + replaysRefreshTrigger, seoRefreshTrigger, }), [ @@ -1311,6 +1327,7 @@ const ViewProjectContent = () => { trafficRefreshTrigger, funnelsRefreshTrigger, profilesRefreshTrigger, + replaysRefreshTrigger, seoRefreshTrigger, ], ) @@ -1596,6 +1613,9 @@ const ViewProjectContent = () => { rotateXAxis={rotateXAxis} /> ) : null} + {activeTab === PROJECT_TABS.replays ? ( + + ) : null} {activeTab === PROJECT_TABS.errors ? ( ) : null} diff --git a/web/app/pages/Project/View/components/ProjectSidebar.tsx b/web/app/pages/Project/View/components/ProjectSidebar.tsx index 742691dc5..3f48ae512 100644 --- a/web/app/pages/Project/View/components/ProjectSidebar.tsx +++ b/web/app/pages/Project/View/components/ProjectSidebar.tsx @@ -69,6 +69,7 @@ const ICON_COLORS: Record = { // Product Analytics profiles: 'text-fuchsia-500', sessions: 'text-indigo-500', + replays: 'text-sky-500', errors: 'text-red-500', goals: 'text-purple-500', experiments: 'text-pink-500', @@ -439,6 +440,7 @@ const ProjectSidebar: React.FC = ({ [ PROJECT_TABS.profiles, PROJECT_TABS.sessions, + PROJECT_TABS.replays, PROJECT_TABS.errors, PROJECT_TABS.goals, PROJECT_TABS.experiments, diff --git a/web/app/pages/Project/View/utils/projectViewSegments.ts b/web/app/pages/Project/View/utils/projectViewSegments.ts index e59f10999..b2ef79e9b 100644 --- a/web/app/pages/Project/View/utils/projectViewSegments.ts +++ b/web/app/pages/Project/View/utils/projectViewSegments.ts @@ -48,6 +48,7 @@ const SEO_FILTER_COLUMNS = ['pg', 'keywords', 'cc', 'dv'] const ANALYTICS_TABS = new Set([ PROJECT_TABS.traffic, PROJECT_TABS.sessions, + PROJECT_TABS.replays, PROJECT_TABS.profiles, PROJECT_TABS.funnels, PROJECT_TABS.goals, @@ -59,6 +60,7 @@ const FILTER_OPTIONS_BY_TAB: Partial> = { [PROJECT_TABS.performance]: PERFORMANCE_FILTER_COLUMNS, [PROJECT_TABS.seo]: ['pg', 'cc', 'dv'], [PROJECT_TABS.sessions]: FILTERS_PANELS_ORDER, + [PROJECT_TABS.replays]: FILTERS_PANELS_ORDER, [PROJECT_TABS.profiles]: FILTERS_PANELS_ORDER, [PROJECT_TABS.funnels]: FILTERS_PANELS_ORDER, [PROJECT_TABS.goals]: FILTERS_PANELS_ORDER, diff --git a/web/app/pages/Project/tabs/Replays/NoReplays.tsx b/web/app/pages/Project/tabs/Replays/NoReplays.tsx new file mode 100644 index 000000000..b6a0a750e --- /dev/null +++ b/web/app/pages/Project/tabs/Replays/NoReplays.tsx @@ -0,0 +1,51 @@ +import { FunnelIcon, MonitorPlayIcon } from '@phosphor-icons/react' +import _isEmpty from 'lodash/isEmpty' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' + +import Filters from '~/pages/Project/View/components/Filters' +import { Filter } from '~/pages/Project/View/interfaces/traffic' +import { typeNameMapping } from '~/pages/Project/View/ViewProject.helpers' +import { Text } from '~/ui/Text' + +interface NoReplaysProps { + filters: Filter[] +} + +const NoReplays = ({ filters }: NoReplaysProps) => { + const { t } = useTranslation('common') + const tnMapping = typeNameMapping(t) + const hasFilters = !_isEmpty(filters) + + return ( + <> + {hasFilters ? : null} +
+
+ {hasFilters ? ( + + ) : ( + + )} +
+ + {hasFilters + ? t('project.noReplaysFiltersTitle') + : t('project.noReplaysTitle')} + + + {hasFilters + ? t('project.noReplaysFiltersDesc') + : t('project.noReplaysContent')} + +
+ + ) +} + +export default memo(NoReplays) diff --git a/web/app/pages/Project/tabs/Replays/Replays.tsx b/web/app/pages/Project/tabs/Replays/Replays.tsx new file mode 100644 index 000000000..5420085bf --- /dev/null +++ b/web/app/pages/Project/tabs/Replays/Replays.tsx @@ -0,0 +1,482 @@ +import cx from 'clsx' +import dayjs from 'dayjs' +import duration from 'dayjs/plugin/duration' +import relativeTime from 'dayjs/plugin/relativeTime' +import utc from 'dayjs/plugin/utc' +import _map from 'lodash/map' +import { + CaretRightIcon, + CursorClickIcon, + FileTextIcon, + ListBulletsIcon, + TrashIcon, + UserIcon, + VideoCameraIcon, + WarningIcon, +} from '@phosphor-icons/react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router' + +import type { SessionReplayListItem } from '~/api/api.server' +import { Badge } from '~/ui/Badge' +import Flag from '~/ui/Flag' +import Button from '~/ui/Button' +import { Link } from '~/ui/Link' +import { Text } from '~/ui/Text' +import Tooltip from '~/ui/Tooltip' +import { BrowserIcon, OSIcon } from '~/pages/Project/tabs/SharedIcons' +import { PROJECT_TABS } from '~/lib/constants' +import { useTheme } from '~/providers/ThemeProvider' +import { getStringFromTime, getTimeFromSeconds } from '~/utils/generic' +import countries from '~/utils/isoCountries' +import { getProfileDisplayName, ProfileAvatar } from '~/utils/profileAvatars' + +dayjs.extend(duration) +dayjs.extend(relativeTime) +dayjs.extend(utc) + +const CURRENCY_SYMBOLS: Record = { + USD: '$', + EUR: '€', + GBP: '£', + CAD: 'CA$', + AUD: 'A$', +} + +const getCurrencySymbol = (currency?: string) => + CURRENCY_SYMBOLS[currency || 'USD'] || CURRENCY_SYMBOLS.USD + +interface ReplaysProps { + replays: SessionReplayListItem[] + timeFormat: '12-hour' | '24-hour' + timezone: string + currency?: string + onDeleteReplay?: (replay: SessionReplayListItem) => void + deletingReplayKey?: string | null +} + +interface ReplayRowProps extends Omit { + replay: SessionReplayListItem +} + +const formatDate = ( + value: string | null | undefined, + language: string, + timeFormat: '12-hour' | '24-hour', + timezone: string, +) => { + if (!value) return null + + const date = dayjs.utc(value) + if (!date.isValid()) return null + + return date.toDate().toLocaleDateString(language, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hourCycle: timeFormat === '12-hour' ? 'h12' : 'h23', + timeZone: timezone, + }) +} + +const ReplayRow = ({ + replay, + timeFormat, + timezone, + currency, + onDeleteReplay, + deletingReplayKey, +}: ReplayRowProps) => { + const { + t, + i18n: { language }, + } = useTranslation('common') + const location = useLocation() + const { theme } = useTheme() + const currencySymbol = getCurrencySymbol(currency) + const replayKey = `${replay.psid}:${replay.replayId}` + const isDeleting = deletingReplayKey === replayKey + + const displayName = useMemo(() => { + if (!replay.profileId) { + return t('project.unknownUser') + } + + return getProfileDisplayName(replay.profileId, Boolean(replay.isIdentified)) + }, [replay.profileId, replay.isIdentified, t]) + + const replayStartedAt = useMemo( + () => + formatDate( + replay.replayStart || replay.replayCreatedAt, + language, + timeFormat, + timezone, + ), + [ + language, + replay.replayCreatedAt, + replay.replayStart, + timeFormat, + timezone, + ], + ) + const replayExpiresAt = useMemo( + () => formatDate(replay.replayExpiresAt, language, timeFormat, timezone), + [language, replay.replayExpiresAt, timeFormat, timezone], + ) + const durationString = useMemo(() => { + if (!replay.replayDuration || replay.replayDuration <= 0) return null + return getStringFromTime(getTimeFromSeconds(replay.replayDuration)) + }, [replay.replayDuration]) + const dateLineString = useMemo(() => { + if (!replayStartedAt) return durationString + + const durationDisplay = durationString ? ` • ${durationString}` : '' + + return ( + + {replayStartedAt} + {durationDisplay} + + ) + }, [durationString, replayStartedAt]) + + const replayParams = new URLSearchParams(location.search) + replayParams.set('tab', PROJECT_TABS.replays) + replayParams.set('psid', replay.psid) + replayParams.set('replayId', replay.replayId) + replayParams.delete('profileId') + + const sessionParams = new URLSearchParams(location.search) + sessionParams.set('tab', PROJECT_TABS.sessions) + sessionParams.set('psid', replay.psid) + sessionParams.delete('profileId') + sessionParams.delete('replayId') + + const profileParams = new URLSearchParams(location.search) + profileParams.set('tab', PROJECT_TABS.profiles) + if (replay.profileId) { + profileParams.set('profileId', replay.profileId) + } + profileParams.delete('psid') + profileParams.delete('replayId') + + return ( +
  • +
    + +
    +
    + {replay.profileId ? ( + + ) : ( +
    + + ? + +
    + )} +
    + +
    +
    +
    + + {displayName} + + {Boolean(replay.isIdentified) && ( + + )} +
    + +
    + + {dateLineString} + +
    +
    + +
    +
    +
    + +
    + } + /> + + +
    + } + /> + + +
    + } + /> +
    + +
    + +
    + + + {replay.eventCount} + + } + /> + + + + {replay.pageviews} + + } + /> + + {replay.customEvents > 0 ? ( + + + {replay.customEvents} + + } + /> + ) : null} + + {replay.revenue != null && replay.revenue !== 0 ? ( + 0, + 'text-amber-600 dark:text-amber-500': + replay.revenue < 0, + })} + > + {replay.revenue < 0 ? '-' : ''} + {currencySymbol} + {Math.abs(replay.revenue).toFixed(2)} + + } + /> + ) : null} + + {replay.errors > 0 ? ( + + + {replay.errors} + + } + /> + ) : null} +
    + + {replayExpiresAt ? ( + <> +
    + + {t('project.sessionReplay.expiresAt', { + date: replayExpiresAt, + })} + + + ) : null} +
    +
    +
    +
    + +
    +
    + + {dateLineString} + +
    +
    +
    +
    + + +
    + + + + } + /> + {replay.profileId ? ( + + + + } + /> + ) : null} + {onDeleteReplay ? ( + onDeleteReplay(replay)} + > + + + } + /> + ) : null} +
    + +
    + + {onDeleteReplay ? ( + + ) : null} +
    +
  • + + ) +} + +export const Replays = ({ + replays, + timeFormat, + timezone, + currency, + onDeleteReplay, + deletingReplayKey, +}: ReplaysProps) => { + return ( +
      + {_map(replays, (replay) => ( + + ))} +
    + ) +} diff --git a/web/app/pages/Project/tabs/Replays/ReplaysView.tsx b/web/app/pages/Project/tabs/Replays/ReplaysView.tsx new file mode 100644 index 000000000..5fac94875 --- /dev/null +++ b/web/app/pages/Project/tabs/Replays/ReplaysView.tsx @@ -0,0 +1,442 @@ +import _isEmpty from 'lodash/isEmpty' +import { + Component, + lazy, + Suspense, + use, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { useLoaderData, useRevalidator, useSearchParams } from 'react-router' +import { toast } from 'sonner' + +import type { + SessionReplayListItem, + SessionReplayMetadata, + SessionReplaysResponse, +} from '~/api/api.server' +import { + useDeleteSessionReplayProxy, + useSessionReplaysProxy, +} from '~/hooks/useAnalyticsProxy' +import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' +import Filters from '~/pages/Project/View/components/Filters' +import ProjectViewHeaderActions from '~/pages/Project/View/components/ProjectViewHeaderActions' +import { + useRefreshTriggers, + useViewProjectContext, +} from '~/pages/Project/View/ViewProject' +import { getFormatDate } from '~/pages/Project/View/ViewProject.helpers' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' +import type { ProjectLoaderData } from '~/routes/projects.$id' +import InfiniteScrollTrigger from '~/ui/InfiniteScrollTrigger' +import LoadingBar from '~/ui/LoadingBar' +import Modal from '~/ui/Modal' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' + +import { LoaderView } from '../../View/components/LoaderView' +import NoReplays from './NoReplays' +import { Replays } from './Replays' + +const SessionReplayModal = lazy(() => import('../Sessions/SessionReplayModal')) + +const REPLAYS_TAKE = 30 + +interface ReplaysErrorBoundaryState { + hasError: boolean + error: Error | null +} + +class ReplaysErrorBoundary extends Component< + { children: ReactNode }, + ReplaysErrorBoundaryState +> { + constructor(props: { children: ReactNode }) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): ReplaysErrorBoundaryState { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return ( + window.location.reload(), + primary: true, + }, + { label: 'Support', to: routes.contact }, + ]} + /> + ) + } + + return this.props.children + } +} + +interface ReplaysViewProps { + tnMapping: Record +} + +interface DeferredReplaysData { + replaysData: SessionReplaysResponse | null +} + +function ReplaysDataResolver({ + children, +}: { + children: (data: DeferredReplaysData) => React.ReactNode +}) { + const { replaysData: replaysDataPromise } = useLoaderData() + + const replaysData = replaysDataPromise ? use(replaysDataPromise) : null + + return <>{children({ replaysData })} +} + +const getReplayMetadata = ( + replay: SessionReplayListItem | null, + replayId: string, +): SessionReplayMetadata => ({ + hasReplay: true, + replayId: replay?.replayId || replayId, + privacyMode: replay?.privacyMode || 'normal', + chunkCount: replay?.chunkCount || 0, + eventCount: replay?.eventCount || 0, + replayDuration: replay?.replayDuration || 0, + replayExpiresAt: replay?.replayExpiresAt || '', +}) + +function ReplaysViewWrapper(props: ReplaysViewProps) { + return ( + + }> + + {(deferredData) => ( + + )} + + + + ) +} + +interface ReplaysViewInnerProps extends ReplaysViewProps { + deferredData: DeferredReplaysData +} + +const ReplaysViewInner = ({ + tnMapping, + deferredData, +}: ReplaysViewInnerProps) => { + const { id, project } = useCurrentProject() + const revalidator = useRevalidator() + const { replaysRefreshTrigger } = useRefreshTriggers() + const { timezone, period, dateRange, filters, timeFormat } = + useViewProjectContext() + const { t } = useTranslation('common') + const [searchParams, setSearchParams] = useSearchParams() + const replaysProxy = useSessionReplaysProxy() + const deleteReplayProxy = useDeleteSessionReplayProxy() + + const [replays, setReplays] = useState( + () => deferredData.replaysData?.replays || [], + ) + const [replayToDelete, setReplayToDelete] = + useState(null) + const [replaysSkip, setReplaysSkip] = useState(REPLAYS_TAKE) + const [canLoadMoreReplays, setCanLoadMoreReplays] = useState( + () => (deferredData.replaysData?.replays?.length || 0) >= REPLAYS_TAKE, + ) + const [error, setError] = useState(null) + const isMountedRef = useRef(true) + const requestIdRef = useRef(0) + const hasShownContentRef = useRef( + !_isEmpty(deferredData.replaysData?.replays || []), + ) + + const activePSID = searchParams.get('psid') + const activeReplayId = searchParams.get('replayId') + const activeReplay = useMemo(() => { + if (!activePSID || !activeReplayId) return null + + return ( + replays.find( + (replay) => + replay.psid === activePSID && replay.replayId === activeReplayId, + ) || null + ) + }, [activePSID, activeReplayId, replays]) + const replayToDeleteKey = replayToDelete + ? `${replayToDelete.psid}:${replayToDelete.replayId}` + : null + + const replaysLoading = + revalidator.state === 'loading' || replaysProxy.isLoading + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + useEffect(() => { + if (deferredData.replaysData && revalidator.state === 'idle') { + const replaysList = deferredData.replaysData.replays || [] + setReplays(replaysList) + setReplaysSkip(REPLAYS_TAKE) + setCanLoadMoreReplays(replaysList.length >= REPLAYS_TAKE) + hasShownContentRef.current = !_isEmpty(replaysList) + setError(null) + } + }, [deferredData.replaysData, revalidator.state]) + + const getReplaysParams = (take: number, skip: number) => { + let from: string | undefined + let to: string | undefined + + if (dateRange) { + from = getFormatDate(dateRange[0]) + to = getFormatDate(dateRange[1]) + } + + return { + timeBucket: 'day', + period: period === 'custom' ? '' : period, + from, + to, + timezone, + filters, + take, + skip, + } + } + + const loadReplays = async ( + forcedSkip?: number, + override?: boolean, + options: { take?: number; limit?: number } = {}, + ) => { + if (replaysLoading) return + + const requestId = ++requestIdRef.current + const skip = typeof forcedSkip === 'number' ? forcedSkip : replaysSkip + const take = options.take || REPLAYS_TAKE + + const data = await replaysProxy.fetchSessionReplays( + id, + getReplaysParams(take, skip), + ) + + if (requestId !== requestIdRef.current || !isMountedRef.current) { + return + } + + if (!data) { + setError(replaysProxy.error || t('apiNotifications.somethingWentWrong')) + return + } + + const newReplays = data.replays || [] + const visibleReplays = + typeof options.limit === 'number' + ? newReplays.slice(0, options.limit) + : newReplays + + setError(null) + + if (override) { + setReplays(visibleReplays) + setReplaysSkip(visibleReplays.length) + hasShownContentRef.current = !_isEmpty(visibleReplays) + } else { + setReplays((prev) => { + const existingIds = new Set( + prev.map((replay) => `${replay.psid}:${replay.replayId}`), + ) + const uniqueNewReplays = visibleReplays.filter( + (replay) => !existingIds.has(`${replay.psid}:${replay.replayId}`), + ) + return [...prev, ...uniqueNewReplays] + }) + setReplaysSkip((prev) => prev + visibleReplays.length) + } + + if (typeof options.limit === 'number') { + setCanLoadMoreReplays(newReplays.length > options.limit) + } else { + setCanLoadMoreReplays(newReplays.length >= take) + } + } + + useEffect(() => { + if (replaysRefreshTrigger > 0) { + const limit = Math.max(replays.length, REPLAYS_TAKE) + loadReplays(0, true, { take: limit + 1, limit }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [replaysRefreshTrigger]) + + const closeReplay = () => { + const params = new URLSearchParams(searchParams) + params.delete('psid') + params.delete('replayId') + setSearchParams(params) + } + + const removeReplayFromList = (psid: string, replayId: string) => { + const removedReplayKey = `${psid}:${replayId}` + const removed = replays.some( + (replay) => `${replay.psid}:${replay.replayId}` === removedReplayKey, + ) + + setReplays((prev) => { + const next = prev.filter( + (replay) => `${replay.psid}:${replay.replayId}` !== removedReplayKey, + ) + hasShownContentRef.current = !_isEmpty(next) + return next + }) + if (removed) { + setReplaysSkip((prev) => Math.max(0, prev - 1)) + } + } + + const deleteReplay = async () => { + if (!replayToDelete || deleteReplayProxy.isLoading) return + + const deletedReplay = replayToDelete + const result = await deleteReplayProxy.deleteSessionReplay( + id, + deletedReplay.psid, + deletedReplay.replayId, + ) + + if (!result) { + toast.error( + deleteReplayProxy.error || t('project.sessionReplay.deleteFailed'), + ) + return + } + + removeReplayFromList(deletedReplay.psid, deletedReplay.replayId) + + if ( + activePSID === deletedReplay.psid && + activeReplayId === deletedReplay.replayId + ) { + closeReplay() + } + + setReplayToDelete(null) + toast.success(t('project.sessionReplay.deleted')) + } + + if (error && !replaysLoading) { + return ( + window.location.reload(), + primary: true, + }, + { label: t('notFoundPage.support'), to: routes.contact }, + ]} + /> + ) + } + + return ( + <> + } + /> + {replaysLoading && !_isEmpty(replays) ? : null} + {!_isEmpty(replays) ? ( + + ) : null} + {replaysLoading && _isEmpty(replays) ? : null} + {!replaysLoading && _isEmpty(replays) && !hasShownContentRef.current ? ( + + ) : null} + + loadReplays()} + disabled={replaysLoading} + className={replaysLoading && _isEmpty(replays) ? 'hidden' : ''} + spinnerClassName='mr-0! ml-0!' + /> + {activePSID && activeReplayId ? ( + + { + if (deletedReplayId) { + removeReplayFromList(activePSID, deletedReplayId) + } + }} + /> + + ) : null} + { + if (!deleteReplayProxy.isLoading) { + setReplayToDelete(null) + } + }} + onSubmit={deleteReplay} + closeText={t('common.cancel')} + submitText={t('common.delete')} + submitType='danger' + isLoading={deleteReplayProxy.isLoading} + /> + + ) +} + +const ReplaysView = ReplaysViewWrapper + +export default ReplaysView diff --git a/web/app/pages/Project/tabs/Sessions/Pageflow.tsx b/web/app/pages/Project/tabs/Sessions/Pageflow.tsx index 985d18ae1..9c39863a7 100644 --- a/web/app/pages/Project/tabs/Sessions/Pageflow.tsx +++ b/web/app/pages/Project/tabs/Sessions/Pageflow.tsx @@ -26,22 +26,29 @@ import { cn, getStringFromTime, getTimeFromSeconds } from '~/utils/generic' dayjs.extend(utc) -interface Metadata { +interface PageflowMetadata { key: string value: string } -type EventType = 'pageview' | 'event' | 'error' | 'sale' | 'refund' +export type PageflowEventType = + | 'pageview' + | 'event' + | 'error' + | 'sale' + | 'refund' + +export interface PageflowEvent { + type: PageflowEventType + value: string + created: string + metadata?: PageflowMetadata[] + amount?: number + currency?: string +} interface PageflowProps { - pages: { - type: EventType - value: string - created: string - metadata?: Metadata[] - amount?: number - currency?: string - }[] + pages: PageflowEvent[] timeFormat: '12-hour' | '24-hour' zoomedTimeRange?: [Date, Date] | null sdur?: number @@ -53,8 +60,8 @@ interface PageflowItemProps { index: number value: string created: string - type: EventType - metadata?: Metadata[] + type: PageflowEventType + metadata?: PageflowMetadata[] displayCreated: string timeDuration: number | null t: TFunction @@ -63,7 +70,10 @@ interface PageflowItemProps { websiteUrl?: string | null } -const ICON_BY_TYPE: Record = { +export const PAGEFLOW_ICON_BY_TYPE: Record< + PageflowEventType, + React.ElementType +> = { pageview: FileTextIcon, event: CursorClickIcon, error: WarningIcon, @@ -71,7 +81,7 @@ const ICON_BY_TYPE: Record = { refund: ArrowCounterClockwiseIcon, } -const ICON_COLOR_BY_TYPE: Record = { +export const PAGEFLOW_ICON_COLOR_BY_TYPE: Record = { pageview: 'text-yellow-500', event: 'text-green-500', error: 'text-red-500', @@ -141,13 +151,17 @@ const TypeIcon = ({ type, className, }: { - type: EventType + type: PageflowEventType className?: string }) => { - const Icon = ICON_BY_TYPE[type] + const Icon = PAGEFLOW_ICON_BY_TYPE[type] return ( @@ -170,7 +184,7 @@ const MetadataPanel = ({ metadata, t, }: { - metadata: Metadata[] + metadata: PageflowMetadata[] t: TFunction }) => { const [showAll, setShowAll] = useState(false) @@ -240,7 +254,7 @@ const MetadataPanel = ({ ) } -const formatAmount = (amount: number, currency?: string) => { +export const formatAmount = (amount: number, currency?: string) => { try { return new Intl.NumberFormat('en-US', { style: 'currency', diff --git a/web/app/pages/Project/tabs/Sessions/SessionDetailView.tsx b/web/app/pages/Project/tabs/Sessions/SessionDetailView.tsx index f65d5a6e9..0aaadff76 100644 --- a/web/app/pages/Project/tabs/Sessions/SessionDetailView.tsx +++ b/web/app/pages/Project/tabs/Sessions/SessionDetailView.tsx @@ -7,21 +7,26 @@ import { GlobeIcon, ClockIcon, LinkIcon, + PlayIcon, UserIcon, SignInIcon, } from '@phosphor-icons/react' -import { useMemo, useState } from 'react' +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '~/ui/Link' import { BrowserIcon, OSIcon } from '../SharedIcons' import { InfoRow, PanelSection } from '../components/DetailPanels' import { PROJECT_TABS } from '~/lib/constants' -import { SessionDetails as SessionDetailsType } from '~/lib/models/Project' +import { + SessionDetails as SessionDetailsType, + SessionReplayMetadata, +} from '~/lib/models/Project' import { BackButton } from '~/pages/Project/View/components/BackButton' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { useCurrentProject } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' +import Button from '~/ui/Button' import PulsatingCircle from '~/ui/icons/PulsatingCircle' import Loader from '~/ui/Loader' import { Text } from '~/ui/Text' @@ -36,19 +41,12 @@ import Flag from '~/ui/Flag' import countries from '~/utils/isoCountries' import NoSessionDetails from './NoSessionDetails' -import { Pageflow } from './Pageflow' +import { Pageflow, type PageflowEvent } from './Pageflow' import { SessionChart } from './SessionChart' dayjs.extend(utc) -interface PageflowItem { - type: 'pageview' | 'event' | 'error' | 'sale' | 'refund' - value: string - created: string - metadata?: { key: string; value: string }[] - amount?: number - currency?: string -} +const SessionReplayModal = lazy(() => import('./SessionReplayModal')) interface SessionDetailViewProps { activeSession: { @@ -59,10 +57,11 @@ interface SessionDetailViewProps { customEvents?: number[] errors?: number[] } - pages?: PageflowItem[] + pages?: PageflowEvent[] timeBucket?: string sessionStart?: string lastActivity?: string + replay?: SessionReplayMetadata | null } | null sessionId?: string | null sessionLoading: boolean @@ -164,6 +163,8 @@ export const SessionDetailView = ({ const [zoomedTimeRange, setZoomedTimeRange] = useState<[Date, Date] | null>( null, ) + const [isReplayOpen, setIsReplayOpen] = useState(false) + const [isReplayDeleted, setIsReplayDeleted] = useState(false) const isTouchDevice = useMemo(() => { if (typeof window === 'undefined') return false @@ -209,6 +210,10 @@ export const SessionDetailView = ({ setZoomedTimeRange(null) } + useEffect(() => { + setIsReplayDeleted(false) + }, [activeSession?.replay?.replayId, sessionId]) + if (_isEmpty(activeSession) && sessionLoading) { return } @@ -411,6 +416,9 @@ export const SessionDetailView = ({ disableHoverableContent /> ) + const hasReplay = Boolean( + activeSession.replay?.hasReplay && sessionId && !isReplayDeleted, + ) return (
    @@ -603,6 +611,17 @@ export const SessionDetailView = ({ ) : null} + + {hasReplay ? ( + + ) : null}
    @@ -696,6 +715,24 @@ export const SessionDetailView = ({ ) : null}
    + + {hasReplay && sessionId ? ( + + setIsReplayOpen(false)} + projectId={projectId} + psid={sessionId} + replay={activeSession.replay} + pages={activeSession.pages || []} + timeFormat={timeFormat} + onDeleted={() => { + setIsReplayDeleted(true) + setIsReplayOpen(false) + }} + /> + + ) : null}
    ) } diff --git a/web/app/pages/Project/tabs/Sessions/SessionReplayModal.tsx b/web/app/pages/Project/tabs/Sessions/SessionReplayModal.tsx new file mode 100644 index 000000000..2f17fa679 --- /dev/null +++ b/web/app/pages/Project/tabs/Sessions/SessionReplayModal.tsx @@ -0,0 +1,2528 @@ +import { + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, +} from '@headlessui/react' +import { + CameraIcon, + CaretLeftIcon, + CaretRightIcon, + CheckIcon, + CornersInIcon, + CornersOutIcon, + CursorClickIcon, + DownloadSimpleIcon, + GaugeIcon, + GearSixIcon, + ListBulletsIcon, + PauseIcon, + PlayIcon, + TextTIcon, + TrashIcon, + WarningOctagonIcon, + XIcon, +} from '@phosphor-icons/react' +import type { TFunction } from 'i18next' +import { + type ComponentPropsWithoutRef, + type CSSProperties, + type ElementType, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { Replayer } from 'dom-player' +import type { eventWithTime } from 'dom-player' +import './replayPlayer.css' +import { toast } from 'sonner' + +import type { + SessionReplayExportResponse, + SessionReplayMetadata, + SessionReplayResponse, +} from '~/api/api.server' +import { + useDeleteSessionReplayProxy, + useSessionReplayExportProxy, + useSessionReplayProxy, +} from '~/hooks/useAnalyticsProxy' +import { useViewProjectContext } from '~/pages/Project/View/ViewProject' +import Button from '~/ui/Button' +import Loader from '~/ui/Loader' +import Modal from '~/ui/Modal' +import { Switch } from '~/ui/Switch' +import { Text } from '~/ui/Text' +import Tooltip from '~/ui/Tooltip' +import { downloadBlob } from '~/utils/download' +import { cn } from '~/utils/generic' + +import { + formatAmount as formatPageflowAmount, + PAGEFLOW_ICON_BY_TYPE, + PAGEFLOW_ICON_COLOR_BY_TYPE, + type PageflowEvent, + type PageflowEventType, +} from './Pageflow' + +const SPEEDS = [0.5, 1, 1.5, 2, 4] +const EVENT_FULL_SNAPSHOT = 2 +const EVENT_INCREMENTAL = 3 +const EVENT_META = 4 +const SOURCE_MUTATION = 0 +const SOURCE_MOUSE_INTERACTION = 2 +const SOURCE_SCROLL = 3 +const SOURCE_VIEWPORT_RESIZE = 4 +const SOURCE_INPUT = 5 +const MUTATION_GROUP_MS = 1500 +const PREVIEW_WIDTH = 224 +const PREVIEW_HEIGHT = 126 +const PREVIEW_SEEK_STEP_MS = 500 +const DEFAULT_REPLAY_VIEWPORT = { width: 1280, height: 720 } +const XHTML_XMLNS = 'http://www.w3.org/1999/xhtml' +const SCREENSHOT_ASSET_WAIT_MS = 1000 +const EXPORT_POLL_INTERVAL_MS = 2000 + +type ReplayEvent = Omit & { + data?: Record +} + +interface TimelineStep { + id: string + offset: number + label: string + detail?: string + kind: + | 'navigation' + | 'snapshot' + | 'click' + | 'scroll' + | 'input' + | 'resize' + | 'mutation' +} + +interface PageflowMarker { + id: string + type: PageflowEventType + offset: number + percent: number + label: string + detail: string +} + +interface HoverPreview { + offset: number + percent: number +} + +interface ReplayViewport { + width: number + height: number +} + +interface ReplayViewportChange extends ReplayViewport { + offset: number +} + +interface PlayerSize { + width: number + height: number +} + +interface SessionReplayModalProps { + isOpen: boolean + onClose: () => void + projectId: string + psid: string + replay?: SessionReplayMetadata | null + replayId?: string + pages?: PageflowEvent[] + timeFormat: '12-hour' | '24-hour' + onDeleted?: (replayId?: string) => void +} + +const getTimestamp = (event?: ReplayEvent) => + typeof event?.timestamp === 'number' ? event.timestamp : 0 + +const formatReplayTime = (ms: number) => { + const seconds = Math.max(0, Math.floor(ms / 1000)) + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return `${minutes}:${String(rest).padStart(2, '0')}` +} + +const PAGEFLOW_EVENT_LABEL_DEFAULTS: Record = { + pageview: 'Pageview', + event: 'Event', + error: 'Error', + sale: 'Sale', + refund: 'Refund', +} + +const PAGEFLOW_MARKER_CLASS = + 'bg-white/55 ring-white/35 group-hover:bg-white/85 group-focus-visible:bg-white/85' + +const PAGEFLOW_SYNC_TOLERANCE_MS = 5000 +const TIMEZONE_SUFFIX_PATTERN = /(?:z|[+-]\d{2}:?\d{2})$/i + +const getPageflowTimestamp = (value?: string) => { + if (!value) return Number.NaN + const normalised = TIMEZONE_SUFFIX_PATTERN.test(value) ? value : `${value}Z` + const timestamp = Date.parse(normalised) + return Number.isFinite(timestamp) ? timestamp : Number.NaN +} + +const isTransparentColour = (colour?: string) => { + if (!colour) return true + + const normalised = colour.replace(/\s/g, '').toLowerCase() + return ( + normalised === 'transparent' || + /^rgba?\(.+(?:,|\/)0(?:\.0+)?\)$/.test(normalised) + ) +} + +const escapeSvgAttribute = (value: string) => + value.replace(/&/g, '&').replace(/"/g, '"').replace(/ + 'style' in element ? (element as HTMLElement | SVGElement).style : undefined + +const isElementTag = (element: Element, tagName: string) => + element.tagName.toLowerCase() === tagName + +const getReplayScreenshotBackground = (doc: Document) => { + const view = doc.defaultView + if (!view) return '#ffffff' + + const colours = [ + doc.body ? view.getComputedStyle(doc.body).backgroundColor : undefined, + view.getComputedStyle(doc.documentElement).backgroundColor, + ] + + return colours.find((colour) => !isTransparentColour(colour)) || '#ffffff' +} + +const getReplayFrameSize = (iframe: HTMLIFrameElement, doc: Document) => { + const width = + Number(iframe.getAttribute('width')) || + iframe.clientWidth || + doc.documentElement.clientWidth || + DEFAULT_REPLAY_VIEWPORT.width + const height = + Number(iframe.getAttribute('height')) || + iframe.clientHeight || + doc.documentElement.clientHeight || + DEFAULT_REPLAY_VIEWPORT.height + + return { + width: Math.max(1, Math.round(width)), + height: Math.max(1, Math.round(height)), + } +} + +const getReplayScrollPosition = (iframe: HTMLIFrameElement, doc: Document) => ({ + x: Math.max( + 0, + Math.round( + iframe.contentWindow?.scrollX || + doc.documentElement.scrollLeft || + doc.body?.scrollLeft || + 0, + ), + ), + y: Math.max( + 0, + Math.round( + iframe.contentWindow?.scrollY || + doc.documentElement.scrollTop || + doc.body?.scrollTop || + 0, + ), + ), +}) + +const waitForReplayScreenshotAssets = async (doc: Document) => { + const fontPromise = + doc.fonts?.ready.then(() => undefined).catch(() => undefined) || + Promise.resolve() + const imagePromise = Promise.all( + Array.from(doc.images, (image) => { + if (image.complete) return Promise.resolve() + + return new Promise((resolve) => { + image.addEventListener('load', () => resolve(), { once: true }) + image.addEventListener('error', () => resolve(), { once: true }) + }) + }), + ).then(() => undefined) + const timeoutPromise = new Promise((resolve) => + window.setTimeout(resolve, SCREENSHOT_ASSET_WAIT_MS), + ) + + await Promise.race([ + Promise.all([fontPromise, imagePromise]).then(() => undefined), + timeoutPromise, + ]) + await new Promise((resolve) => + window.requestAnimationFrame(() => resolve()), + ) +} + +const getReplayStylesheetText = (doc: Document) => { + const rules: string[] = [] + + Array.from(doc.styleSheets).forEach((sheet) => { + try { + Array.from(sheet.cssRules).forEach((rule) => rules.push(rule.cssText)) + } catch { + return + } + }) + + return rules.join('\n') +} + +const inlineReplayElementStyle = ( + source: Element, + target: Element, + view: Window, +) => { + const targetStyle = getElementStyle(target) + if (!targetStyle) return + + const computed = view.getComputedStyle(source) + for (let index = 0; index < computed.length; index += 1) { + const property = computed.item(index) + if (!property) continue + + targetStyle.setProperty( + property, + computed.getPropertyValue(property), + computed.getPropertyPriority(property), + ) + } +} + +const copyReplayElementState = (source: Element, target: Element) => { + if (isElementTag(source, 'input') && isElementTag(target, 'input')) { + const sourceInput = source as HTMLInputElement + const targetInput = target as HTMLInputElement + + targetInput.value = sourceInput.value + targetInput.setAttribute('value', sourceInput.value) + + if (sourceInput.checked) { + targetInput.setAttribute('checked', '') + } else { + targetInput.removeAttribute('checked') + } + } else if ( + isElementTag(source, 'textarea') && + isElementTag(target, 'textarea') + ) { + const sourceTextArea = source as HTMLTextAreaElement + const targetTextArea = target as HTMLTextAreaElement + + targetTextArea.value = sourceTextArea.value + targetTextArea.textContent = sourceTextArea.value + } else if (isElementTag(source, 'select') && isElementTag(target, 'select')) { + const sourceSelect = source as HTMLSelectElement + const targetSelect = target as HTMLSelectElement + + Array.from(sourceSelect.options).forEach((option, index) => { + const targetOption = targetSelect.options[index] + if (!targetOption) return + + targetOption.selected = option.selected + if (option.selected) { + targetOption.setAttribute('selected', '') + } else { + targetOption.removeAttribute('selected') + } + }) + } +} + +const replaceReplayCanvas = ( + source: Element, + target: Element, + doc: Document, +) => { + if (!isElementTag(source, 'canvas') || !isElementTag(target, 'canvas')) { + return + } + + try { + const sourceCanvas = source as HTMLCanvasElement + const targetCanvas = target as HTMLCanvasElement + const image = doc.createElement('img') + image.src = sourceCanvas.toDataURL('image/png') + image.width = sourceCanvas.width + image.height = sourceCanvas.height + image.style.cssText = targetCanvas.style.cssText + targetCanvas.replaceWith(image) + } catch { + return + } +} + +const buildReplayScreenshotDocument = ( + doc: Document, + backgroundColour: string, +) => { + const view = doc.defaultView + if (!view) throw new Error('Replay frame is unavailable') + + const cloneRoot = doc.documentElement.cloneNode(true) as HTMLElement + cloneRoot.setAttribute('xmlns', XHTML_XMLNS) + + const sourceElements = [ + doc.documentElement, + ...Array.from(doc.documentElement.querySelectorAll('*')), + ] + const cloneElements = [ + cloneRoot, + ...Array.from(cloneRoot.querySelectorAll('*')), + ] + + sourceElements.forEach((source, index) => { + const target = cloneElements[index] + if (!target) return + + inlineReplayElementStyle(source, target, view) + copyReplayElementState(source, target) + replaceReplayCanvas(source, target, doc) + }) + + let cloneHead = cloneRoot.querySelector('head') + if (!cloneHead) { + cloneHead = doc.createElement('head') + cloneRoot.insertBefore(cloneHead, cloneRoot.firstChild) + } + + const base = doc.createElement('base') + base.href = doc.baseURI || window.location.href + cloneHead.prepend(base) + + const stylesheetText = getReplayStylesheetText(doc) + if (stylesheetText) { + const style = doc.createElement('style') + style.textContent = stylesheetText + cloneHead.appendChild(style) + } + + getElementStyle(cloneRoot)?.setProperty( + 'background-color', + backgroundColour, + 'important', + ) + const cloneBody = cloneRoot.querySelector('body') + if (cloneBody) { + getElementStyle(cloneBody)?.setProperty( + 'background-color', + backgroundColour, + 'important', + ) + } + + return cloneRoot +} + +const buildReplayScreenshotSvg = ( + iframe: HTMLIFrameElement, + doc: Document, + width: number, + height: number, + backgroundColour: string, +) => { + const cloneRoot = buildReplayScreenshotDocument(doc, backgroundColour) + const scroll = getReplayScrollPosition(iframe, doc) + const contentWidth = Math.max( + width + scroll.x, + doc.documentElement.scrollWidth, + doc.body?.scrollWidth || 0, + ) + const contentHeight = Math.max( + height + scroll.y, + doc.documentElement.scrollHeight, + doc.body?.scrollHeight || 0, + ) + const serialized = new XMLSerializer().serializeToString(cloneRoot) + const fill = escapeSvgAttribute(backgroundColour) + + return [ + ``, + ``, + ``, + serialized, + '', + ].join('') +} + +const formatReplayStartDate = ( + timestamp: number, + language: string, + timeFormat: '12-hour' | '24-hour', + timezone: string, +) => { + if (!Number.isFinite(timestamp) || timestamp <= 0) return null + + return new Date(timestamp).toLocaleDateString(language, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hourCycle: timeFormat === '12-hour' ? 'h12' : 'h23', + timeZone: timezone, + }) +} + +const getPageflowEventLabel = (type: PageflowEventType, t: TFunction) => + String( + t(`project.sessionReplay.timelineEvents.${type}`, { + defaultValue: PAGEFLOW_EVENT_LABEL_DEFAULTS[type], + }), + ) + +const getPageflowEventDetail = (event: PageflowEvent, t: TFunction) => { + if (event.type === 'sale' || event.type === 'refund') { + const sign = event.type === 'refund' ? '-' : '+' + const amount = `${sign}${formatPageflowAmount( + event.amount || 0, + event.currency, + )}` + return event.value ? `${amount} · ${event.value}` : amount + } + + return event.value || String(t('common.notSet', { defaultValue: 'Not set' })) +} + +const buildPageflowMarkers = ( + pages: PageflowEvent[] | undefined, + replayStartTimestamp: number, + duration: number, + t: TFunction, +): PageflowMarker[] => { + if ( + !pages?.length || + !Number.isFinite(replayStartTimestamp) || + replayStartTimestamp <= 0 || + duration <= 0 + ) { + return [] + } + + return pages + .flatMap((event, index) => { + const timestamp = getPageflowTimestamp(event.created) + const offset = timestamp - replayStartTimestamp + + if ( + !Number.isFinite(offset) || + offset < -PAGEFLOW_SYNC_TOLERANCE_MS || + offset > duration + PAGEFLOW_SYNC_TOLERANCE_MS + ) { + return [] + } + + const safeOffset = Math.min(duration, Math.max(0, offset)) + + return { + id: `${event.type}-${event.created}-${index}`, + type: event.type, + offset: safeOffset, + percent: (safeOffset / duration) * 100, + label: getPageflowEventLabel(event.type, t), + detail: getPageflowEventDetail(event, t), + } + }) + .sort((a, b) => a.offset - b.offset) +} + +const getEventDetail = (event: ReplayEvent) => { + const href = event.data?.href + if (typeof href === 'string') { + try { + const url = new URL(href) + return `${url.pathname}${url.search}` + } catch { + return href + } + } + + const text = event.data?.text || event.data?.value + if (typeof text === 'string') { + return text.length > 48 ? `${text.slice(0, 48)}...` : text + } + + return undefined +} + +const buildTimelineSteps = ( + events: ReplayEvent[], + t: TFunction, +): TimelineStep[] => { + const firstTimestamp = getTimestamp(events[0]) + const steps: TimelineStep[] = [] + let mutationGroup: TimelineStep | null = null + let mutationCount = 0 + + const pushMutationGroup = () => { + if (!mutationGroup) return + steps.push({ + ...mutationGroup, + detail: + mutationCount > 1 + ? t('project.sessionReplay.timeline.mutationsCount', { + count: mutationCount, + }) + : mutationGroup.detail, + }) + mutationGroup = null + mutationCount = 0 + } + + events.forEach((event, index) => { + const timestamp = getTimestamp(event) + const offset = Math.max(0, timestamp - firstTimestamp) + const source = event.data?.source + + if ( + event.type !== EVENT_INCREMENTAL || + source !== SOURCE_MUTATION || + !mutationGroup || + offset - mutationGroup.offset > MUTATION_GROUP_MS + ) { + pushMutationGroup() + } + + if (event.type === EVENT_META) { + steps.push({ + id: `${index}-navigation`, + offset, + kind: 'navigation', + label: t('project.sessionReplay.timeline.navigation'), + detail: getEventDetail(event), + }) + return + } + + if (event.type === EVENT_FULL_SNAPSHOT) { + steps.push({ + id: `${index}-snapshot`, + offset, + kind: 'snapshot', + label: t('project.sessionReplay.timeline.fullSnapshot'), + }) + return + } + + if (event.type !== EVENT_INCREMENTAL) { + return + } + + if (source === SOURCE_MUTATION) { + mutationGroup = { + id: `${index}-mutation`, + offset, + kind: 'mutation', + label: t('project.sessionReplay.timeline.mutations'), + } + mutationCount += 1 + return + } + + if (source === SOURCE_MOUSE_INTERACTION) { + steps.push({ + id: `${index}-click`, + offset, + kind: 'click', + label: t('project.sessionReplay.timeline.click'), + }) + return + } + + if (source === SOURCE_SCROLL) { + steps.push({ + id: `${index}-scroll`, + offset, + kind: 'scroll', + label: t('project.sessionReplay.timeline.scroll'), + }) + return + } + + if (source === SOURCE_INPUT) { + steps.push({ + id: `${index}-input`, + offset, + kind: 'input', + label: t('project.sessionReplay.timeline.input'), + detail: getEventDetail(event), + }) + return + } + + if (source === SOURCE_VIEWPORT_RESIZE) { + steps.push({ + id: `${index}-resize`, + offset, + kind: 'resize', + label: t('project.sessionReplay.timeline.resize'), + }) + } + }) + + pushMutationGroup() + return steps.slice(0, 200) +} + +const TIMELINE_KIND_META: Record< + TimelineStep['kind'], + { Icon: ElementType; iconClass: string } +> = { + navigation: { + Icon: PlayIcon, + iconClass: 'text-sky-600 dark:text-sky-400', + }, + snapshot: { + Icon: PlayIcon, + iconClass: 'text-slate-600 dark:text-slate-300', + }, + click: { + Icon: CursorClickIcon, + iconClass: 'text-emerald-600 dark:text-emerald-400', + }, + scroll: { + Icon: CaretRightIcon, + iconClass: 'text-gray-600 dark:text-gray-300', + }, + input: { + Icon: TextTIcon, + iconClass: 'text-indigo-600 dark:text-slate-300', + }, + resize: { + Icon: CaretRightIcon, + iconClass: 'text-amber-600 dark:text-amber-400', + }, + mutation: { + Icon: ListBulletsIcon, + iconClass: 'text-gray-500 dark:text-gray-400', + }, +} + +const getTimelineMeta = (kind: TimelineStep['kind']) => { + return TIMELINE_KIND_META[kind] +} + +const getNearestStep = (steps: TimelineStep[], offset: number) => { + let nearest: TimelineStep | null = null + + for (const step of steps) { + if (step.offset > offset) break + nearest = step + } + + return nearest +} + +const getReplayViewportFromEvent = ( + event: ReplayEvent, +): ReplayViewport | null => { + const width = Number(event.data?.width) + const height = Number(event.data?.height) + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null + } + + return { width, height } +} + +const buildReplayViewportChanges = ( + events: ReplayEvent[], +): ReplayViewportChange[] => { + const firstTimestamp = getTimestamp(events[0]) + const changes: ReplayViewportChange[] = [] + + events.forEach((event) => { + const isViewportEvent = + event.type === EVENT_META || + (event.type === EVENT_INCREMENTAL && + event.data?.source === SOURCE_VIEWPORT_RESIZE) + + if (!isViewportEvent) return + + const viewport = getReplayViewportFromEvent(event) + if (!viewport) return + + changes.push({ + ...viewport, + offset: Math.max(0, getTimestamp(event) - firstTimestamp), + }) + }) + + return changes.length ? changes : [{ ...DEFAULT_REPLAY_VIEWPORT, offset: 0 }] +} + +const getReplayViewportAt = ( + changes: ReplayViewportChange[], + offset: number, +) => { + let viewport = changes[0] || { ...DEFAULT_REPLAY_VIEWPORT, offset: 0 } + + for (const change of changes) { + if (change.offset > offset) break + viewport = change + } + + return viewport +} + +const getContainScale = ( + containerWidth: number, + containerHeight: number, + contentWidth: number, + contentHeight: number, +) => { + if ( + containerWidth <= 0 || + containerHeight <= 0 || + contentWidth <= 0 || + contentHeight <= 0 + ) { + return 1 + } + + return Math.max( + 0.01, + Math.min(containerWidth / contentWidth, containerHeight / contentHeight), + ) +} + +const TimelineRailLine = () => ( +
    +
    +
    +) + +const TimelineEventItem = ({ + step, + isActive, + isLast, + onSeek, +}: { + step: TimelineStep + isActive: boolean + isLast: boolean + onSeek: (offset: number, shouldPlay?: boolean) => void +}) => { + const { Icon, iconClass } = getTimelineMeta(step.kind) + + return ( +
  • +
    + + + + {!isLast ? : null} +
    + +
    + +
    +
  • + ) +} + +const PlayerIconButton = ({ + className, + ...props +}: ComponentPropsWithoutRef) => ( + + } + /> + ) +} + +const SETTINGS_ROW_CLASS = + 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-slate-100 transition-colors duration-150 ease-out hover:bg-white/10 focus-visible:bg-white/10 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent' + +const SessionReplayModal = ({ + isOpen, + onClose, + projectId, + psid, + replay, + replayId, + pages = [], + timeFormat, + onDeleted, +}: SessionReplayModalProps) => { + const { + t, + i18n: { language }, + } = useTranslation('common') + const { timezone } = useViewProjectContext() + const { fetchSessionReplay, isLoading } = useSessionReplayProxy() + const deleteReplayProxy = useDeleteSessionReplayProxy() + const { + startSessionReplayExport, + getSessionReplayExportStatus, + getSessionReplayExportDownloadUrl, + } = useSessionReplayExportProxy() + const [payload, setPayload] = useState(null) + const [error, setError] = useState(null) + const [isPreparing, setIsPreparing] = useState(false) + const [isExportStarting, setIsExportStarting] = useState(false) + const [exportStatus, setExportStatus] = + useState(null) + const [duration, setDuration] = useState(0) + const [currentTime, setCurrentTime] = useState(0) + const [isPlaying, setIsPlaying] = useState(false) + const [speed, setSpeed] = useState(1) + const [showTimeline, setShowTimeline] = useState(false) + const [isFullscreen, setIsFullscreen] = useState(false) + const [hoverPreview, setHoverPreview] = useState(null) + const [isPlayerHovered, setIsPlayerHovered] = useState(false) + const [isControlsFocused, setIsControlsFocused] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false) + const [settingsView, setSettingsView] = useState<'main' | 'speed'>('main') + const [bezel, setBezel] = useState<{ + kind: 'play' | 'pause' + key: number + } | null>(null) + const [playerSize, setPlayerSize] = useState({ + width: 0, + height: 0, + }) + const playerRoot = useRef(null) + const previewRoot = useRef(null) + const settingsRef = useRef(null) + const fullscreenRoot = useRef(null) + const replayer = useRef(null) + const previewReplayer = useRef(null) + const previewSeekRaf = useRef(null) + const exportPollTimeout = useRef(null) + const exportAbortController = useRef(null) + const isModalOpenRef = useRef(isOpen) + const playStart = useRef({ offset: 0, at: 0, speed: 1 }) + const currentTimeRef = useRef(0) + const speedRef = useRef(1) + + const events = useMemo( + () => (payload?.events || []) as ReplayEvent[], + [payload?.events], + ) + const replayStartTimestamp = getTimestamp(events[0]) + const timelineSteps = useMemo( + () => buildTimelineSteps(events, t), + [events, t], + ) + const hasEvents = events.length > 0 + const viewportChanges = useMemo( + () => buildReplayViewportChanges(events), + [events], + ) + const activeReplayViewport = useMemo( + () => getReplayViewportAt(viewportChanges, currentTime), + [currentTime, viewportChanges], + ) + const previewReplayViewport = useMemo( + () => + getReplayViewportAt(viewportChanges, hoverPreview?.offset ?? currentTime), + [currentTime, hoverPreview?.offset, viewportChanges], + ) + const playerScale = getContainScale( + playerSize.width, + playerSize.height, + activeReplayViewport.width, + activeReplayViewport.height, + ) + const playerStyle = { + '--replay-player-width': `${activeReplayViewport.width}px`, + '--replay-player-height': `${activeReplayViewport.height}px`, + '--replay-player-scale': playerScale, + } as CSSProperties + const previewScale = Math.min( + 1, + getContainScale( + PREVIEW_WIDTH, + PREVIEW_HEIGHT, + previewReplayViewport.width, + previewReplayViewport.height, + ), + ) + const previewStyle = { + '--replay-preview-width': `${previewReplayViewport.width}px`, + '--replay-preview-height': `${previewReplayViewport.height}px`, + '--replay-preview-scale': previewScale, + } as CSSProperties + const progressPercent = + duration > 0 + ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) + : 0 + const progressStyle = { + '--replay-progress': `${progressPercent}%`, + '--replay-progress-scale': progressPercent / 100, + } as CSSProperties + const activeStep = useMemo( + () => getNearestStep(timelineSteps, currentTime), + [currentTime, timelineSteps], + ) + const pageflowMarkers = useMemo( + () => buildPageflowMarkers(pages, replayStartTimestamp, duration, t), + [duration, pages, replayStartTimestamp, t], + ) + const replayStartDateTimestamp = + replayStartTimestamp || getPageflowTimestamp(pages[0]?.created) + const replayStartDate = useMemo(() => { + return formatReplayStartDate( + replayStartDateTimestamp, + language, + timeFormat, + timezone, + ) + }, [language, replayStartDateTimestamp, timeFormat, timezone]) + const shouldShowPreview = + Boolean(hoverPreview) && hasEvents && !error && duration > 0 + const canControlReplay = hasEvents && !error && duration > 0 + const isReplayExporting = + isExportStarting || + exportStatus?.status === 'queued' || + exportStatus?.status === 'processing' + const exportProgress = Math.max( + 0, + Math.min(100, Math.round(exportStatus?.progress || 0)), + ) + const shouldShowControls = + !isPlaying || + isPlayerHovered || + isControlsFocused || + settingsOpen || + !canControlReplay + const speedLabel = + speed === 1 ? t('project.sessionReplay.normalSpeed') : `${speed}×` + const selectedReplayId = replayId || replay?.replayId + const exportToastId = `session-replay-export-${psid}-${selectedReplayId || 'latest'}` + + const clearExportPoll = useCallback(() => { + if (exportPollTimeout.current) { + window.clearTimeout(exportPollTimeout.current) + exportPollTimeout.current = null + } + exportAbortController.current?.abort() + exportAbortController.current = null + }, []) + + useEffect(() => { + isModalOpenRef.current = isOpen + }, [isOpen]) + + useEffect(() => { + currentTimeRef.current = currentTime + }, [currentTime]) + + useEffect(() => { + speedRef.current = speed + }, [speed]) + + useEffect(() => { + if (!isOpen || !hasEvents || !playerRoot.current) return + + const root = playerRoot.current + const updatePlayerSize = () => { + const rect = root.getBoundingClientRect() + const width = Math.round(rect.width) + const height = Math.round(rect.height) + + setPlayerSize((value) => + value.width === width && value.height === height + ? value + : { width, height }, + ) + } + + updatePlayerSize() + + const observer = new ResizeObserver(updatePlayerSize) + observer.observe(root) + + return () => observer.disconnect() + }, [hasEvents, isOpen]) + + useEffect(() => { + if (!isOpen) return + + let cancelled = false + setIsPreparing(true) + setPayload(null) + setError(null) + setDuration(0) + setCurrentTime(0) + setIsPlaying(false) + setHoverPreview(null) + setIsPlayerHovered(false) + setIsControlsFocused(false) + setPlayerSize({ width: 0, height: 0 }) + + fetchSessionReplay(projectId, psid, selectedReplayId) + .then((result) => { + if (cancelled) return + if (!result) { + setError(t('project.sessionReplay.loadError')) + return + } + setPayload(result) + }) + .catch((reason) => { + if (cancelled) return + setError( + reason instanceof Error + ? reason.message + : t('project.sessionReplay.loadError'), + ) + }) + .finally(() => { + if (!cancelled) setIsPreparing(false) + }) + + return () => { + cancelled = true + } + }, [fetchSessionReplay, isOpen, projectId, psid, selectedReplayId, t]) + + useEffect(() => { + if (!isOpen || !hasEvents || !playerRoot.current) return + + const root = playerRoot.current + root.innerHTML = '' + setIsPlaying(false) + setCurrentTime(0) + + try { + const instance = new Replayer(events as eventWithTime[], { + root, + speed: speedRef.current, + insertStyleRules: ['html, body { background-color: #fff; }'], + }) + replayer.current = instance + const metadata = instance.getMetaData() + const fallbackDuration = + events.length > 1 + ? getTimestamp(events[events.length - 1]) - getTimestamp(events[0]) + : 0 + setDuration(Math.max(0, metadata.totalTime || fallbackDuration)) + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : t('project.sessionReplay.playerError'), + ) + } + + return () => { + replayer.current?.pause() + replayer.current = null + root.innerHTML = '' + } + }, [events, hasEvents, isOpen, t]) + + useEffect(() => { + if (!shouldShowPreview || !previewRoot.current) return + + const root = previewRoot.current + root.innerHTML = '' + + try { + previewReplayer.current = new Replayer(events as eventWithTime[], { + root, + speed: 1, + mouseTail: false, + insertStyleRules: ['html, body { background-color: #fff; }'], + showWarning: false, + }) + } catch { + previewReplayer.current = null + root.innerHTML = '' + } + + return () => { + if (previewSeekRaf.current) { + window.cancelAnimationFrame(previewSeekRaf.current) + previewSeekRaf.current = null + } + previewReplayer.current?.pause() + previewReplayer.current = null + root.innerHTML = '' + } + }, [events, shouldShowPreview]) + + useEffect(() => { + if (!hoverPreview || !previewReplayer.current) return + + if (previewSeekRaf.current) { + window.cancelAnimationFrame(previewSeekRaf.current) + } + + previewSeekRaf.current = window.requestAnimationFrame(() => { + previewReplayer.current?.pause(hoverPreview.offset) + previewSeekRaf.current = null + }) + + return () => { + if (previewSeekRaf.current) { + window.cancelAnimationFrame(previewSeekRaf.current) + previewSeekRaf.current = null + } + } + }, [hoverPreview]) + + useEffect(() => { + if (!isOpen || !isPlaying || duration <= 0) return + + const timer = window.setInterval(() => { + const elapsed = + (performance.now() - playStart.current.at) * playStart.current.speed + const next = Math.min(duration, playStart.current.offset + elapsed) + setCurrentTime(next) + + if (next >= duration) { + replayer.current?.pause(duration) + setIsPlaying(false) + } + }, 250) + + return () => window.clearInterval(timer) + }, [duration, isOpen, isPlaying]) + + useEffect(() => { + const onFullscreenChange = () => { + setIsFullscreen(Boolean(document.fullscreenElement)) + } + + document.addEventListener('fullscreenchange', onFullscreenChange) + return () => + document.removeEventListener('fullscreenchange', onFullscreenChange) + }, []) + + useEffect(() => { + if (!settingsOpen) { + setSettingsView('main') + return + } + + const onPointerDown = (event: PointerEvent) => { + if ( + settingsRef.current && + !settingsRef.current.contains(event.target as Node | null) + ) { + setSettingsOpen(false) + } + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setSettingsOpen(false) + } + + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('keydown', onKeyDown) + } + }, [settingsOpen]) + + useEffect(() => { + if (!isOpen) setSettingsOpen(false) + }, [isOpen]) + + useEffect(() => { + if (isOpen) return + + clearExportPoll() + setIsExportStarting(false) + setExportStatus(null) + }, [clearExportPoll, isOpen]) + + useEffect(() => { + return () => clearExportPoll() + }, [clearExportPoll]) + + const flashBezel = useCallback((kind: 'play' | 'pause') => { + setBezel({ kind, key: performance.now() }) + }, []) + + const syncPlaybackOffset = useCallback(() => { + if (!isPlaying) return currentTimeRef.current + + const elapsed = + (performance.now() - playStart.current.at) * playStart.current.speed + return Math.min(duration, playStart.current.offset + elapsed) + }, [duration, isPlaying]) + + const play = useCallback(() => { + if (!replayer.current || duration <= 0) return + + const offset = Math.min(currentTimeRef.current, Math.max(0, duration - 1)) + replayer.current.play(offset) + playStart.current = { offset, at: performance.now(), speed } + setIsPlaying(true) + }, [duration, speed]) + + const pause = useCallback(() => { + if (!replayer.current) return + + const offset = syncPlaybackOffset() + replayer.current.pause(offset) + setCurrentTime(offset) + setIsPlaying(false) + }, [syncPlaybackOffset]) + + const togglePlayback = useCallback(() => { + if (!canControlReplay) return + + if (isPlaying) { + pause() + flashBezel('pause') + } else { + play() + flashBezel('play') + } + }, [canControlReplay, isPlaying, pause, play, flashBezel]) + + const seekTo = useCallback( + (offset: number, shouldPlay = isPlaying) => { + if (!replayer.current) return + + const nextOffset = Math.max(0, Math.min(duration, offset)) + if (shouldPlay) { + replayer.current.play(nextOffset) + } else { + replayer.current.pause(nextOffset) + } + setCurrentTime(nextOffset) + currentTimeRef.current = nextOffset + + if (shouldPlay) { + playStart.current = { + offset: nextOffset, + at: performance.now(), + speed, + } + setIsPlaying(true) + } else { + setIsPlaying(false) + } + }, + [duration, isPlaying, speed], + ) + + const handlePlayerClick = useCallback( + (event: ReactMouseEvent) => { + if (!canControlReplay) return + // Clicks on the controls bar / settings menu drive their own actions. + if ((event.target as HTMLElement).closest('[data-replay-controls]')) { + return + } + // First click outside an open settings menu only dismisses it. + if (settingsOpen) { + setSettingsOpen(false) + return + } + togglePlayback() + }, + [canControlReplay, settingsOpen, togglePlayback], + ) + + const handlePlayerKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!canControlReplay) return + // Let focused controls (scrubber, buttons, menu) handle their own keys. + if ((event.target as HTMLElement).closest('[data-replay-controls]')) { + return + } + + if (event.key === ' ' || event.key === 'k') { + event.preventDefault() + togglePlayback() + } else if (event.key === 'ArrowLeft' || event.key === 'j') { + event.preventDefault() + seekTo(Math.max(0, syncPlaybackOffset() - 5000)) + } else if (event.key === 'ArrowRight' || event.key === 'l') { + event.preventDefault() + seekTo(Math.min(duration, syncPlaybackOffset() + 5000)) + } + }, + [canControlReplay, duration, seekTo, syncPlaybackOffset, togglePlayback], + ) + + const updateHoverPreview = useCallback( + (event: ReactMouseEvent) => { + if (!hasEvents || error || duration <= 0) { + setHoverPreview(null) + return + } + + const rect = event.currentTarget.getBoundingClientRect() + const percent = Math.max( + 0, + Math.min(1, (event.clientX - rect.left) / rect.width), + ) + const offset = Math.min( + duration, + Math.max( + 0, + Math.round((percent * duration) / PREVIEW_SEEK_STEP_MS) * + PREVIEW_SEEK_STEP_MS, + ), + ) + + setHoverPreview((value) => { + if ( + value && + Math.abs(value.offset - offset) < 1 && + Math.abs(value.percent - percent) < 0.005 + ) { + return value + } + + return { offset, percent } + }) + }, + [duration, error, hasEvents], + ) + + const setPlaybackSpeed = (nextSpeed: number) => { + const offset = syncPlaybackOffset() + setSpeed(nextSpeed) + replayer.current?.setConfig({ speed: nextSpeed }) + setCurrentTime(offset) + + if (isPlaying) { + replayer.current?.play(offset) + playStart.current = { + offset, + at: performance.now(), + speed: nextSpeed, + } + } + } + + const toggleFullscreen = async () => { + try { + if (document.fullscreenElement) { + await document.exitFullscreen() + } else { + await fullscreenRoot.current?.requestFullscreen() + } + } catch { + toast.error(t('project.sessionReplay.fullscreenError')) + } + } + + const downloadScreenshot = async () => { + try { + const iframe = + replayer.current?.iframe || playerRoot.current?.querySelector('iframe') + const doc = iframe?.contentDocument + if (!iframe || !doc) { + throw new Error('Replay frame is unavailable') + } + + await waitForReplayScreenshotAssets(doc) + + const { width, height } = getReplayFrameSize(iframe, doc) + const backgroundColour = getReplayScreenshotBackground(doc) + const svgMarkup = buildReplayScreenshotSvg( + iframe, + doc, + width, + height, + backgroundColour, + ) + const svg = new Blob([svgMarkup], { + type: 'image/svg+xml;charset=utf-8', + }) + const url = URL.createObjectURL(svg) + const image = new Image() + + try { + await new Promise((resolve, reject) => { + image.onload = () => resolve() + image.onerror = () => reject(new Error('Screenshot failed')) + image.src = url + }) + } finally { + URL.revokeObjectURL(url) + } + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) throw new Error('Screenshot failed') + context.fillStyle = backgroundColour + context.fillRect(0, 0, width, height) + context.drawImage(image, 0, 0, width, height) + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/png'), + ) + if (!blob) throw new Error('Screenshot failed') + + downloadBlob(blob, `swetrix-replay-${psid}.png`) + toast.success(t('project.sessionReplay.screenshotDownloaded')) + } catch { + toast.error(t('project.sessionReplay.screenshotError')) + } + } + + const downloadExport = useCallback( + (exportId: string) => { + const anchor = document.createElement('a') + anchor.href = getSessionReplayExportDownloadUrl(projectId, exportId) + anchor.rel = 'noopener' + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + }, + [getSessionReplayExportDownloadUrl, projectId], + ) + + const handleExportStatus = useCallback( + (status: SessionReplayExportResponse) => { + if (!isModalOpenRef.current) return true + + setExportStatus(status) + + if (status.status === 'ready') { + clearExportPoll() + setIsExportStarting(false) + downloadExport(status.exportId) + toast.success(t('project.sessionReplay.exportDownloaded'), { + id: exportToastId, + }) + setExportStatus(null) + return true + } + + if (status.status === 'failed' || status.status === 'expired') { + clearExportPoll() + setIsExportStarting(false) + toast.error(status.error || t('project.sessionReplay.exportFailed'), { + id: exportToastId, + }) + setExportStatus(null) + return true + } + + toast.loading( + t('project.sessionReplay.exportPreparingProgress', { + progress: Math.max(0, Math.min(100, Math.round(status.progress))), + }), + { id: exportToastId }, + ) + + return false + }, + [clearExportPoll, downloadExport, exportToastId, t], + ) + + const startExportPolling = useCallback( + (exportId: string) => { + clearExportPoll() + + const poll = async () => { + if (!isModalOpenRef.current) return + + const controller = new AbortController() + exportAbortController.current = controller + + try { + const status = await getSessionReplayExportStatus( + projectId, + exportId, + controller.signal, + ) + + if (!isModalOpenRef.current || controller.signal.aborted) return + if (handleExportStatus(status)) return + + if (!isModalOpenRef.current) return + exportPollTimeout.current = window.setTimeout( + poll, + EXPORT_POLL_INTERVAL_MS, + ) + } catch { + if (!isModalOpenRef.current || controller.signal.aborted) return + + clearExportPoll() + setIsExportStarting(false) + setExportStatus(null) + toast.error(t('project.sessionReplay.exportFailed'), { + id: exportToastId, + }) + } finally { + if (exportAbortController.current === controller) { + exportAbortController.current = null + } + } + } + + if (isModalOpenRef.current) { + exportPollTimeout.current = window.setTimeout( + poll, + EXPORT_POLL_INTERVAL_MS, + ) + } + }, + [ + clearExportPoll, + exportToastId, + getSessionReplayExportStatus, + handleExportStatus, + projectId, + t, + ], + ) + + const exportReplay = useCallback(async () => { + if (!canControlReplay || isReplayExporting) return + + clearExportPoll() + const controller = new AbortController() + exportAbortController.current = controller + setIsExportStarting(true) + setSettingsOpen(false) + toast.loading(t('project.sessionReplay.exportPreparing'), { + id: exportToastId, + }) + + try { + const status = await startSessionReplayExport( + projectId, + psid, + selectedReplayId, + controller.signal, + ) + + if (!isModalOpenRef.current || controller.signal.aborted) return + + setIsExportStarting(false) + + if (!handleExportStatus(status)) { + startExportPolling(status.exportId) + } + } catch (reason) { + if (!isModalOpenRef.current || controller.signal.aborted) return + + setIsExportStarting(false) + setExportStatus(null) + toast.error( + reason instanceof Error + ? reason.message + : t('project.sessionReplay.exportFailed'), + { id: exportToastId }, + ) + } finally { + if (exportAbortController.current === controller) { + exportAbortController.current = null + } + } + }, [ + canControlReplay, + clearExportPoll, + exportToastId, + handleExportStatus, + isReplayExporting, + projectId, + psid, + selectedReplayId, + startExportPolling, + startSessionReplayExport, + t, + ]) + + const close = () => { + isModalOpenRef.current = false + replayer.current?.pause() + clearExportPoll() + setIsPlaying(false) + setIsExportStarting(false) + setExportStatus(null) + setHoverPreview(null) + setIsPlayerHovered(false) + setIsControlsFocused(false) + onClose() + } + + const deleteReplay = async () => { + if (!selectedReplayId || deleteReplayProxy.isLoading) return + + const result = await deleteReplayProxy.deleteSessionReplay( + projectId, + psid, + selectedReplayId, + ) + + if (!result) { + toast.error( + deleteReplayProxy.error || t('project.sessionReplay.deleteFailed'), + ) + return + } + + setIsDeleteConfirmOpen(false) + toast.success(t('project.sessionReplay.deleted')) + onDeleted?.(selectedReplayId) + close() + } + + return ( + + +
    + +
    +
    +
    + + + {t('project.sessionReplay.title')} + + + {replayStartDate ? ( + + ) : null} +
    +
    +
    + +
    +
    + +
    +
    setIsPlayerHovered(true)} + onMouseLeave={() => { + setIsPlayerHovered(false) + setHoverPreview(null) + }} + > + {isPreparing || isLoading ? ( +
    + +
    + ) : null} + {!isPreparing && error ? ( +
    +
    + + + {t('project.sessionReplay.loadError')} + + + {error} + +
    +
    + ) : null} + {!isPreparing && !error && payload && !hasEvents ? ( +
    + + {t('project.sessionReplay.empty')} + +
    + ) : null} + +
    + + {/* + * Transparent layer above the replay iframe so clicks and keys reach + * React; an iframe would otherwise swallow them. + */} + {canControlReplay ? ( + + + + + + + + + +
    + ) : ( +
    + +
    +
    + {SPEEDS.map((item) => ( + + ))} +
    +
    + )} +
    + ) : null} +
    + + + {isFullscreen ? ( + + ) : ( + + )} + + } + /> +
    +
    +
    +
    +
    + + +
    + +
    + { + if (!deleteReplayProxy.isLoading) { + setIsDeleteConfirmOpen(false) + } + }} + onSubmit={deleteReplay} + closeText={t('common.cancel')} + submitText={t('common.delete')} + submitType='danger' + isLoading={deleteReplayProxy.isLoading} + /> + + ) +} + +export default SessionReplayModal diff --git a/web/app/pages/Project/tabs/Sessions/Sessions.tsx b/web/app/pages/Project/tabs/Sessions/Sessions.tsx index 89367d478..f4d01d59e 100644 --- a/web/app/pages/Project/tabs/Sessions/Sessions.tsx +++ b/web/app/pages/Project/tabs/Sessions/Sessions.tsx @@ -9,6 +9,7 @@ import { WarningIcon, CursorClickIcon, CaretRightIcon, + VideoCameraIcon, } from '@phosphor-icons/react' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -178,6 +179,24 @@ export const Session = ({ () => dayjs.utc(session.lastActivity).fromNow(true), [session.lastActivity], ) + const replayDurationString = useMemo(() => { + if (!session.replayDuration || session.replayDuration <= 0) return null + return getStringFromTime(getTimeFromSeconds(session.replayDuration)) + }, [session.replayDuration]) + const replayExpiresAt = useMemo(() => { + if (!session.replayExpiresAt) return null + const date = dayjs.utc(session.replayExpiresAt) + if (!date.isValid()) return null + + return date.toDate().toLocaleDateString(language, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hourCycle: timeFormat === '12-hour' ? 'h12' : 'h23', + timeZone: timezone, + }) + }, [language, session.replayExpiresAt, timeFormat, timezone]) const params = new URLSearchParams(location.search) params.set('psid', session.psid) @@ -308,6 +327,43 @@ export const Session = ({
    + {Boolean(session.hasReplay) && ( + + + {t('project.sessionReplay.available')} + + {replayDurationString ? ( + + {t('project.sessionReplay.duration', { + duration: replayDurationString, + })} + + ) : null} + {replayExpiresAt ? ( + + {t('project.sessionReplay.expiresAt', { + date: replayExpiresAt, + })} + + ) : null} +
    + } + tooltipNode={ + + + + } + /> + )} + .replayer-wrapper { + position: absolute; + top: 50%; + left: 50%; + width: var(--replay-player-width, 100%); + height: var(--replay-player-height, 100%); + overflow: hidden; + background: #fff; + transform: translate(-50%, -50%) scale(var(--replay-player-scale, 1)); + transform-origin: center; +} +.rrweb-player-root iframe { + border: 0; +} +.rrweb-preview-root { + position: relative; + overflow: hidden; + background: #fff; +} +.rrweb-preview-root > .replayer-wrapper { + position: absolute; + top: 50%; + left: 50%; + width: var(--replay-preview-width); + height: var(--replay-preview-height); + transform: translate(-50%, -50%) scale(var(--replay-preview-scale)); + transform-origin: center; + pointer-events: none; +} +.rrweb-preview-root iframe, +.rrweb-preview-root canvas { + pointer-events: none; +} +.replay-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) 0px; + transition: grid-template-rows 240ms cubic-bezier(0.32, 0.72, 0, 1); +} +.replay-timeline-panel { + opacity: 0; + transform: translate3d(20px, 0, 0); + transition: + transform 240ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 160ms ease-out, + border-color 160ms ease-out; + will-change: transform, opacity; + pointer-events: none; +} +.replay-layout[data-timeline='open'] { + grid-template-rows: minmax(0, 1fr) clamp(220px, 34dvh, 360px); +} +.replay-layout[data-timeline='open'] .replay-timeline-panel { + opacity: 1; + transform: translate3d(0, 0, 0); + pointer-events: auto; +} +@media (min-width: 80rem) { + .replay-layout { + grid-template-columns: minmax(0, 1fr) 0px; + grid-template-rows: minmax(0, 1fr); + transition: grid-template-columns 240ms cubic-bezier(0.32, 0.72, 0, 1); + } + .replay-layout[data-timeline='open'] { + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-rows: minmax(0, 1fr); + } +} +.replay-scrubber { + cursor: pointer; +} +.replay-scrubber:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.replay-scrubber:focus-visible { + outline: none; +} +.replay-progress-fill { + transform: scaleX(var(--replay-progress-scale, 0)); + transform-origin: left center; +} +.replay-progress-thumb { + left: var(--replay-progress, 0%); + transform: translate3d(-50%, -50%, 0) scale(0); +} +.group\/scrubber:hover .replay-progress-thumb { + transform: translate3d(-50%, -50%, 0) scale(1); +} +[data-playing='true'] .replay-progress-fill { + transition: transform 250ms linear; +} +[data-playing='true'] .replay-progress-thumb { + transition: + transform 100ms cubic-bezier(0.23, 1, 0.32, 1), + left 250ms linear; +} +[data-playing='false'] .replay-progress-fill { + transition: transform 120ms cubic-bezier(0.23, 1, 0.32, 1); +} +[data-playing='false'] .replay-progress-thumb { + transition: + transform 100ms cubic-bezier(0.23, 1, 0.32, 1), + left 120ms cubic-bezier(0.23, 1, 0.32, 1); +} +/* + * The visible track, fill and handle are drawn with regular DOM nodes (see + * SessionReplayModal). This native range input only provides the hit area, + * dragging and keyboard support, so its own track and thumb are fully + * transparent and stretched to the full grab height. + */ +.replay-scrubber::-webkit-slider-runnable-track { + height: 16px; + background: transparent; +} +.replay-scrubber::-webkit-slider-thumb { + width: 16px; + height: 16px; + margin-top: 0; + appearance: none; + border: 0; + border-radius: 9999px; + background: transparent; +} +.replay-scrubber::-moz-range-track { + height: 16px; + background: transparent; +} +.replay-scrubber::-moz-range-thumb { + width: 16px; + height: 16px; + border: 0; + border-radius: 9999px; + background: transparent; +} + +/* + * YouTube-style center bezel that flashes the play / pause glyph in the middle + * of the player whenever playback is toggled. + */ +@keyframes replay-bezel-pop { + 0% { + opacity: 0; + transform: scale(0.6); + } + 15% { + opacity: 0.95; + transform: scale(0.85); + } + 100% { + opacity: 0; + transform: scale(1.3); + } +} +.replay-bezel { + animation: replay-bezel-pop 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} +@media (prefers-reduced-motion: reduce) { + .replay-layout, + .replay-timeline-panel { + transition-duration: 1ms; + transform: none; + } + .replay-progress-fill, + .replay-progress-thumb { + transition: none !important; + } + .replay-bezel { + animation: none; + opacity: 0; + } +} +.replayer-mouse { + position: absolute; + width: 20px; + height: 20px; + transition: + left 0.05s linear, + top 0.05s linear; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; + background-image: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg=='); + border-color: transparent; /* otherwise we transition from black when .touch-device class is added */ +} +.replayer-mouse::after { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + background: rgb(73, 80, 246); + border-radius: 100%; + transform: translate(-50%, -50%); + opacity: 0.3; +} +.replayer-mouse.active::after { + animation: click 0.2s ease-in-out 1; +} +.replayer-mouse.touch-device { + background-image: none; /* there's no passive cursor on touch-only screens */ + width: 70px; + height: 70px; + border-width: 4px; + border-style: solid; + border-radius: 100%; + margin-left: -37px; + margin-top: -37px; + border-color: rgba(73, 80, 246, 0); + transition: + left 0s linear, + top 0s linear, + border-color 0.2s ease-in-out; +} +.replayer-mouse.touch-device.touch-active { + border-color: rgba(73, 80, 246, 1); + transition: + left 0.25s linear, + top 0.25s linear, + border-color 0.2s ease-in-out; +} +.replayer-mouse.touch-device::after { + opacity: 0; /* there's no passive cursor on touch-only screens */ +} +.replayer-mouse.touch-device.active::after { + animation: touch-click 0.2s ease-in-out 1; +} +.replayer-mouse-tail { + position: absolute; + pointer-events: none; +} + +@keyframes click { + 0% { + opacity: 0.3; + width: 20px; + height: 20px; + } + 50% { + opacity: 0.5; + width: 10px; + height: 10px; + } +} + +@keyframes touch-click { + 0% { + opacity: 0; + width: 20px; + height: 20px; + } + 50% { + opacity: 0.5; + width: 10px; + height: 10px; + } +} diff --git a/web/app/pages/Subscribe/Subscribe.tsx b/web/app/pages/Subscribe/Subscribe.tsx index 541ce371e..79108f710 100644 --- a/web/app/pages/Subscribe/Subscribe.tsx +++ b/web/app/pages/Subscribe/Subscribe.tsx @@ -12,6 +12,7 @@ import { Trans, useTranslation } from 'react-i18next' import { useFetcher, useLoaderData, useNavigate } from 'react-router' import { toast } from 'sonner' +import SessionReplayPricingTooltip from '~/components/pricing/SessionReplayPricingTooltip' import { usePaddle } from '~/hooks/usePaddle' import { CURRENCIES, paddleLanguageMapping, TRIAL_DAYS } from '~/lib/constants' import { @@ -447,23 +448,26 @@ const Subscribe = () => {
    -
    +
    {getPlanName(selectedPlanType, t)} - - {formatEventsLong( - EVENT_TIERS[selectedEventTier].monthlyEvents, - i18n.language, - )}{' '} - {t('pricing.eventsWithReplay', { - replayQuota: formatReplayQuota( - replayQuota, - t, +
    + + {formatEventsLong( + EVENT_TIERS[selectedEventTier].monthlyEvents, i18n.language, - ), - })} - + )}{' '} + {t('pricing.eventsWithReplay', { + replayQuota: formatReplayQuota( + replayQuota, + t, + i18n.language, + ), + })} + + +
    {monthlyPrice === null ? ( diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index 0dcd35747..5c65c4624 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -58,7 +58,6 @@ import { DEFAULT_METAINFO } from '~/lib/models/Metainfo' import { UsageInfo } from '~/lib/models/Usageinfo' import { User } from '~/lib/models/User' import { - ADDONS, getEffectivePlanType, type BillingInterval, type CurrencyCode, @@ -67,6 +66,7 @@ import PaidFeature from '~/modals/PaidFeature' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' import type { + SessionReplayAddonPreview, UserSettingsActionData, UserSettingsLoaderData, WebsiteAddonPreview, @@ -191,6 +191,7 @@ const DEFAULT_USAGE_INFO: UsageInfo = { customEvents: 0, captcha: 0, projects: 0, + sessionReplays: 0, trafficPerc: 0, errorsPerc: 0, customEventsPerc: 0, @@ -208,9 +209,17 @@ const DEFAULT_USAGE_INFO: UsageInfo = { }, } +const WEBSITE_ADDON_QUANTITY_STEP = 50 +const SESSION_REPLAY_ADDON_QUANTITY_STEP = 5000 +const ADDON_QUANTITY_OPTION_COUNT = 21 + const WEBSITE_ADDON_QUANTITY_OPTIONS = Array.from( - { length: 21 }, - (_, index) => index * 50, + { length: ADDON_QUANTITY_OPTION_COUNT }, + (_, index) => index * WEBSITE_ADDON_QUANTITY_STEP, +) +const SESSION_REPLAY_ADDON_QUANTITY_OPTIONS = Array.from( + { length: ADDON_QUANTITY_OPTION_COUNT }, + (_, index) => index * SESSION_REPLAY_ADDON_QUANTITY_STEP, ) const formatBillingPrice = (amount: number) => @@ -270,6 +279,8 @@ const UserSettings = () => { const metainfoFetcher = useFetcher() const websiteAddonFetcher = useFetcher() const websiteAddonPreviewFetcher = useFetcher() + const sessionReplayAddonFetcher = useFetcher() + const sessionReplayAddonPreviewFetcher = useFetcher() // Stable submit handle for the preview fetcher. Effects must depend on this, // not on the fetcher object (whose identity changes on every state transition @@ -281,6 +292,15 @@ const UserSettings = () => { websiteAddonPreviewFetcherRef.current.submit(...args), [], ) + const sessionReplayAddonPreviewFetcherRef = useRef( + sessionReplayAddonPreviewFetcher, + ) + sessionReplayAddonPreviewFetcherRef.current = sessionReplayAddonPreviewFetcher + const sessionReplayAddonPreviewSubmit = useCallback( + (...args: Parameters) => + sessionReplayAddonPreviewFetcherRef.current.submit(...args), + [], + ) const [searchParams, setSearchParams] = useSearchParams() @@ -317,7 +337,9 @@ const UserSettings = () => { maxApiKeyRequestsPerHour = 0, sessionReplaysIncluded = 0, purchasedWebsiteAddons = 0, + purchasedSessionReplayAddons = 0, websiteAddon, + sessionReplayAddon, isAccountBillingSuspended, } = user || ({} as Partial) @@ -352,16 +374,16 @@ const UserSettings = () => { : 'USD' ) as CurrencyCode const currency = CURRENCIES[currencyCode] - const websiteAddonBundle = ADDONS.websiteBundles[0] - const websiteAddonPrice = websiteAddonBundle.monthly[currencyCode] - // Session replay add-on pricing is hidden until self-serve replay billing ships. - // const sessionReplayAddon = ADDONS.sessionReplayBundles[0] - // const sessionReplayAddonPrice = sessionReplayAddon.monthly[currencyCode] const activeWebsiteAddonQuantity = websiteAddon?.quantity ?? purchasedWebsiteAddons const currentWebsiteAddonBillingInterval = (websiteAddon?.billingInterval || 'monthly') as BillingInterval const isWebsiteAddonLegacy = !!websiteAddon?.isLegacy + const activeSessionReplayAddonQuantity = + sessionReplayAddon?.quantity ?? purchasedSessionReplayAddons + const currentSessionReplayAddonBillingInterval = + (sessionReplayAddon?.billingInterval || 'monthly') as BillingInterval + const isSessionReplayAddonLegacy = !!sessionReplayAddon?.isLegacy const isTrialEnded = (() => { if (!trialEndDate) { @@ -444,6 +466,31 @@ const UserSettings = () => { }) } + const onSessionReplayAddonModalOpen = () => { + setSelectedSessionReplayAddonQuantity(activeSessionReplayAddonQuantity) + setSelectedSessionReplayAddonBillingInterval( + currentSessionReplayAddonBillingInterval, + ) + setIsSessionReplayAddonModalOpened(true) + } + + const onSessionReplayAddonModalClose = () => { + if (isSessionReplayAddonSubmitting) return + setIsSessionReplayAddonModalOpened(false) + } + + const onSessionReplayAddonUpdate = () => { + const formData = new FormData() + formData.set('intent', 'update-session-replay-addon') + formData.set('quantity', String(selectedSessionReplayAddonQuantity)) + formData.set('billingInterval', selectedSessionReplayAddonBillingInterval) + + sessionReplayAddonFetcher.submit(formData, { + method: 'POST', + action: '/user-settings', + }) + } + const tabs = getTabs(t) const sidebarGroups = useMemo[]>( () => [ @@ -508,6 +555,16 @@ const UserSettings = () => { ] = useState(currentWebsiteAddonBillingInterval) const [isWebsiteAddonModalOpened, setIsWebsiteAddonModalOpened] = useState(false) + const [ + selectedSessionReplayAddonQuantity, + setSelectedSessionReplayAddonQuantity, + ] = useState(activeSessionReplayAddonQuantity) + const [ + selectedSessionReplayAddonBillingInterval, + setSelectedSessionReplayAddonBillingInterval, + ] = useState(currentSessionReplayAddonBillingInterval) + const [isSessionReplayAddonModalOpened, setIsSessionReplayAddonModalOpened] = + useState(false) const [showModal, setShowModal] = useState(false) const [showAPIDeleteModal, setShowAPIDeleteModal] = useState(false) const translatedFrequencies = useMemo( @@ -527,6 +584,8 @@ const UserSettings = () => { const lastHandledWebsiteAddonData = useRef( null, ) + const lastHandledSessionReplayAddonData = + useRef(null) const shouldHandleFetcherData = useDeduplicateFetcherResponse() const profileUpdateToast = useRef('profileSettings.updated') @@ -544,6 +603,8 @@ const UserSettings = () => { const isSubmitting = fetcher.state === 'submitting' const isWebsiteAddonSubmitting = websiteAddonFetcher.state !== 'idle' + const isSessionReplayAddonSubmitting = + sessionReplayAddonFetcher.state !== 'idle' const websiteAddonPreview = useMemo(() => { if ( websiteAddonPreviewFetcher.data?.success && @@ -639,16 +700,135 @@ const UserSettings = () => { ) : 0 const displayedPurchasedWebsites = activeWebsiteAddonQuantity - const selectedWebsiteAddonRecurringAmount = - currentWebsiteAddonPreview?.recurringAmount ?? - websiteAddonPrice * - (selectedWebsiteAddonQuantity / websiteAddonBundle.quantity) * - (selectedWebsiteAddonBillingInterval === 'yearly' ? 10 : 1) // Yearly add-on billing is only offered to subscribers on a yearly plan; // monthly subscribers can still buy the add-on, billed monthly. const canSelectYearlyWebsiteAddon = user?.billingFrequency === 'yearly' const activeWebsiteAddonRecurringAmount = websiteAddon?.recurringAmount ?? null + const sessionReplayAddonPreview = useMemo(() => { + if ( + sessionReplayAddonPreviewFetcher.data?.success && + sessionReplayAddonPreviewFetcher.data.intent === + 'preview-session-replay-addon' + ) { + return sessionReplayAddonPreviewFetcher.data + .data as SessionReplayAddonPreview + } + + return null + }, [sessionReplayAddonPreviewFetcher.data]) + const isSessionReplayAddonPreviewLoading = + sessionReplayAddonPreviewFetcher.state !== 'idle' + const currentSessionReplayAddonPreview = useMemo(() => { + if ( + sessionReplayAddonPreview?.quantity === + selectedSessionReplayAddonQuantity && + sessionReplayAddonPreview.billingInterval === + selectedSessionReplayAddonBillingInterval + ) { + return sessionReplayAddonPreview + } + + return null + }, [ + sessionReplayAddonPreview, + selectedSessionReplayAddonQuantity, + selectedSessionReplayAddonBillingInterval, + ]) + const hasPendingSessionReplayAddonChange = + (sessionReplayAddon?.pendingQuantity ?? null) !== null || + (sessionReplayAddon?.pendingBillingInterval ?? null) !== null + const sessionReplayAddonDisabledReason = useMemo(() => { + if (isTrial) { + return t('billing.sessionReplayAddonUnavailableDuringTrial') + } + + if (!isSubscriber) { + return t('billing.sessionReplayAddonRequiresSubscription') + } + + if (cancellationEffectiveDate) { + return t('billing.sessionReplayAddonUnavailableCancelled') + } + + if (isAccountBillingSuspended) { + return t('billing.sessionReplayAddonUnavailableSuspended') + } + + if (sessionReplaysIncluded === 'custom') { + return t('billing.sessionReplayAddonUnavailableCustom') + } + + if (isSessionReplayAddonLegacy) { + return t('billing.sessionReplayAddonLegacyManaged') + } + + return null + }, [ + isSubscriber, + isTrial, + cancellationEffectiveDate, + isAccountBillingSuspended, + sessionReplaysIncluded, + isSessionReplayAddonLegacy, + t, + ]) + const isSessionReplayAddonDisabled = !!sessionReplayAddonDisabledReason + const hasSessionReplayAddonChanges = + selectedSessionReplayAddonQuantity !== activeSessionReplayAddonQuantity || + selectedSessionReplayAddonBillingInterval !== + currentSessionReplayAddonBillingInterval || + hasPendingSessionReplayAddonChange + const sessionReplayAddonPreviewError = + sessionReplayAddonPreviewFetcher.data?.intent === + 'preview-session-replay-addon' && + sessionReplayAddonPreviewFetcher.data.error + ? sessionReplayAddonPreviewFetcher.data.error + : null + const sessionReplayAddonPreviewErrorMessage = sessionReplayAddonPreviewError + ? (() => { + const billingTranslated = t(`billing.${sessionReplayAddonPreviewError}`) + const apiTranslated = t( + `apiNotifications.${sessionReplayAddonPreviewError}`, + ) + + return billingTranslated !== `billing.${sessionReplayAddonPreviewError}` + ? billingTranslated + : apiTranslated !== + `apiNotifications.${sessionReplayAddonPreviewError}` + ? apiTranslated + : sessionReplayAddonPreviewError + })() + : null + const includedSessionReplayLimit = + currentSessionReplayAddonPreview?.includedSessionReplays ?? + (typeof sessionReplaysIncluded === 'number' + ? Math.max(0, sessionReplaysIncluded - activeSessionReplayAddonQuantity) + : sessionReplaysIncluded) + const displayedSessionReplayLimit = + currentSessionReplayAddonPreview?.totalSessionReplays ?? + sessionReplaysIncluded + const displayedSessionReplayLimitLabel = + typeof displayedSessionReplayLimit === 'number' + ? displayedSessionReplayLimit.toLocaleString() + : displayedSessionReplayLimit === 'custom' + ? t('pricing.custom') + : displayedSessionReplayLimit || '0' + const displayedSessionReplayUsage = + typeof displayedSessionReplayLimit === 'number' && + displayedSessionReplayLimit > 0 + ? Math.min( + 100, + _round( + ((usageInfo.sessionReplays || 0) / displayedSessionReplayLimit) * + 100, + 2, + ), + ) + : 0 + const canSelectYearlySessionReplayAddon = user?.billingFrequency === 'yearly' + const activeSessionReplayAddonRecurringAmount = + sessionReplayAddon?.recurringAmount ?? null const formatBillingDate = useCallback( (date?: string | null) => { @@ -672,6 +852,17 @@ const UserSettings = () => { setSelectedWebsiteAddonBillingInterval(currentWebsiteAddonBillingInterval) }, [activeWebsiteAddonQuantity, currentWebsiteAddonBillingInterval, user?.id]) + useEffect(() => { + setSelectedSessionReplayAddonQuantity(activeSessionReplayAddonQuantity) + setSelectedSessionReplayAddonBillingInterval( + currentSessionReplayAddonBillingInterval, + ) + }, [ + activeSessionReplayAddonQuantity, + currentSessionReplayAddonBillingInterval, + user?.id, + ]) + useEffect(() => { if ( activeTab !== TAB_MAPPING.BILLING || @@ -703,6 +894,34 @@ const UserSettings = () => { websiteAddonPreviewSubmit, ]) + useEffect(() => { + if ( + activeTab !== TAB_MAPPING.BILLING || + !user?.id || + isSessionReplayAddonDisabled || + !isSessionReplayAddonModalOpened + ) { + return + } + + const formData = new FormData() + formData.set('intent', 'preview-session-replay-addon') + formData.set('quantity', String(selectedSessionReplayAddonQuantity)) + formData.set('billingInterval', selectedSessionReplayAddonBillingInterval) + sessionReplayAddonPreviewSubmit(formData, { + method: 'POST', + action: '/user-settings', + }) + }, [ + activeTab, + user?.id, + isSessionReplayAddonDisabled, + isSessionReplayAddonModalOpened, + selectedSessionReplayAddonQuantity, + selectedSessionReplayAddonBillingInterval, + sessionReplayAddonPreviewSubmit, + ]) + useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return if (!shouldHandleFetcherData(fetcher.data)) return @@ -843,6 +1062,55 @@ const UserSettings = () => { loadUser, ]) + useEffect(() => { + if ( + sessionReplayAddonFetcher.state !== 'idle' || + !sessionReplayAddonFetcher.data || + lastHandledSessionReplayAddonData.current === + sessionReplayAddonFetcher.data + ) { + return + } + + lastHandledSessionReplayAddonData.current = sessionReplayAddonFetcher.data + + if ( + sessionReplayAddonFetcher.data.success && + sessionReplayAddonFetcher.data.intent === 'update-session-replay-addon' && + sessionReplayAddonFetcher.data.user + ) { + mergeUser(sessionReplayAddonFetcher.data.user) + toast.success(t('billing.sessionReplayAddonUpdated')) + loadUser() + setIsSessionReplayAddonModalOpened(false) + return + } + + if (sessionReplayAddonFetcher.data.error) { + const translated = t( + `apiNotifications.${sessionReplayAddonFetcher.data.error}`, + ) + const billingTranslated = t( + `billing.${sessionReplayAddonFetcher.data.error}`, + ) + + toast.error( + billingTranslated !== `billing.${sessionReplayAddonFetcher.data.error}` + ? billingTranslated + : translated !== + `apiNotifications.${sessionReplayAddonFetcher.data.error}` + ? translated + : sessionReplayAddonFetcher.data.error, + ) + } + }, [ + sessionReplayAddonFetcher.data, + sessionReplayAddonFetcher.state, + mergeUser, + t, + loadUser, + ]) + const flushProfileAutosave = useCallback(() => { const autosave = pendingProfileAutosave.current if (profileAutosaveFetcher.state !== 'idle' || !autosave) return @@ -1996,8 +2264,11 @@ const UserSettings = () => { weight='semibold' className='mt-1' > - {t('billing.perMonthQuota', { - amount: replayLimitLabel, + {t('billing.sessionReplayUsageSummary', { + used: ( + usageInfo.sessionReplays || 0 + ).toLocaleString(), + total: replayLimitLabel, })} { colour='secondary' className='mt-1' > - {t('billing.recordedSessionsQuota')} + {activeSessionReplayAddonQuantity + ? t('billing.addedSessionReplays', { + amount: + activeSessionReplayAddonQuantity.toLocaleString(), + }) + : t('billing.recordedSessionsQuota')}
    @@ -2137,7 +2413,8 @@ const UserSettings = () => { title={t('billing.addonsTitle')} description={t('billing.addonsDesc')} > -
    +
    +
    {t('billing.websiteAddonTitle')} @@ -2148,10 +2425,7 @@ const UserSettings = () => { className='mt-1' > {t('billing.websiteAddonDescription', { - amount: websiteAddonBundle.quantity, - unitPrice: `${currency.symbol}${formatBillingPrice( - websiteAddonPrice / websiteAddonBundle.quantity, - )}`, + amount: WEBSITE_ADDON_QUANTITY_STEP, })} @@ -2250,29 +2524,124 @@ const UserSettings = () => {
    - {/* Additional session replays add-on is temporarily hidden - until self-serve replay pricing is finalised. -
    +
    +
    {t('billing.sessionReplayAddonTitle')} - + {t('billing.sessionReplayAddonDescription', { - amount: sessionReplayAddon.quantity.toLocaleString(), + amount: SESSION_REPLAY_ADDON_QUANTITY_STEP, })} - - {currency.symbol} - {formatBillingPrice(sessionReplayAddonPrice)} - - /{t('pricing.intervals.month')} + +
    +
    + + {t('billing.sessionReplayUsageSummary', { + used: ( + usageInfo.sessionReplays || 0 + ).toLocaleString(), + total: displayedSessionReplayLimitLabel, + })} + + + {t('billing.xPercentUsed', { + percentage: displayedSessionReplayUsage, + })} + +
    +
    +
    +
    +
    + + {t('billing.sessionReplayAddonIncludedCount', { + count: + typeof includedSessionReplayLimit === 'number' + ? includedSessionReplayLimit.toLocaleString() + : t('pricing.custom'), + })} + + + {activeSessionReplayAddonQuantity + ? t( + 'billing.sessionReplayAddonPurchasedCount', + { + count: + activeSessionReplayAddonQuantity.toLocaleString(), + }, + ) + : t('billing.noAdditionalSessionReplays')} + +
    +
    + + {activeSessionReplayAddonQuantity > 0 && + activeSessionReplayAddonRecurringAmount !== null ? ( + + {t('billing.sessionReplayAddonActiveSummary', { + count: + activeSessionReplayAddonQuantity.toLocaleString(), + amount: `${currency.symbol}${formatBillingPrice( + activeSessionReplayAddonRecurringAmount, + )}`, + interval: t( + currentSessionReplayAddonBillingInterval === + 'yearly' + ? 'pricing.intervals.year' + : 'pricing.intervals.month', + ), + })} - - - {t('billing.sessionReplayAddonComingSoon')} - + ) : null} + + {hasPendingSessionReplayAddonChange && + !sessionReplayAddonDisabledReason ? ( + + {t('billing.sessionReplayAddonPendingChange', { + date: + formatBillingDate( + sessionReplayAddon?.nextChargeDate || + sessionReplayAddon?.periodEnd, + ) || t('billing.sessionReplayAddonNextRenewal'), + })} + + ) : null} + + {sessionReplayAddonDisabledReason ? ( + + {sessionReplayAddonDisabledReason} + + ) : null} + +
    - */} )} @@ -2508,10 +2877,7 @@ const UserSettings = () => {
    {t('billing.websiteAddonDescription', { - amount: websiteAddonBundle.quantity, - unitPrice: `${currency.symbol}${formatBillingPrice( - websiteAddonPrice / websiteAddonBundle.quantity, - )}`, + amount: WEBSITE_ADDON_QUANTITY_STEP, })} @@ -2618,7 +2984,7 @@ const UserSettings = () => { {currentWebsiteAddonPreview.quantity > 0 ? `${currency.symbol}${formatBillingPrice( - selectedWebsiteAddonRecurringAmount, + currentWebsiteAddonPreview.recurringAmount, )}/${t( selectedWebsiteAddonBillingInterval === 'yearly' ? 'pricing.intervals.year' @@ -2660,6 +3026,181 @@ const UserSettings = () => { } /> + + + {t('billing.sessionReplayAddonDescription', { + amount: SESSION_REPLAY_ADDON_QUANTITY_STEP, + })} + + + + label={t('billing.sessionReplayAddonQuantity')} + className='w-full' + selectedItem={selectedSessionReplayAddonQuantity} + items={SESSION_REPLAY_ADDON_QUANTITY_OPTIONS} + onSelect={setSelectedSessionReplayAddonQuantity} + title={ + selectedSessionReplayAddonQuantity + ? t('billing.extraSessionReplaysAmount', { + amount: + selectedSessionReplayAddonQuantity.toLocaleString(), + }) + : t('billing.noAdditionalSessionReplays') + } + labelExtractor={(amount) => + amount + ? t('billing.extraSessionReplaysAmount', { + amount: amount.toLocaleString(), + }) + : t('billing.noAdditionalSessionReplays') + } + keyExtractor={(amount) => String(amount)} + /> + +
    + + {t('billing.sessionReplayAddonBillingInterval')} + +
    + {(['monthly', 'yearly'] as BillingInterval[]).map( + (interval) => { + const isActive = + selectedSessionReplayAddonBillingInterval === interval + const isDisabled = + interval === 'yearly' && + !canSelectYearlySessionReplayAddon + + return ( + + ) + }, + )} +
    + + {canSelectYearlySessionReplayAddon + ? t('billing.sessionReplayAddonYearlyDiscount') + : t('billing.sessionReplayAddonYearlyOnlyNote')} + +
    + + {sessionReplayAddonPreviewErrorMessage ? ( + + {sessionReplayAddonPreviewErrorMessage} + + ) : null} + +
    + {isSessionReplayAddonPreviewLoading && + !currentSessionReplayAddonPreview ? ( +
    + +
    + ) : currentSessionReplayAddonPreview ? ( +
    +
    + + {t('billing.sessionReplayAddonDueNow')} + + + {currentSessionReplayAddonPreview.dueNow > 0 + ? `${currency.symbol}${formatBillingPrice( + currentSessionReplayAddonPreview.dueNow, + )}` + : t('billing.sessionReplayAddonNoImmediateCharge')} + +
    +
    + + {t('billing.sessionReplayAddonRecurring')} + + + {currentSessionReplayAddonPreview.quantity > 0 + ? `${currency.symbol}${formatBillingPrice( + currentSessionReplayAddonPreview.recurringAmount, + )}/${t( + selectedSessionReplayAddonBillingInterval === + 'yearly' + ? 'pricing.intervals.year' + : 'pricing.intervals.month', + )}` + : t('billing.sessionReplayAddonNoNextCharge')} + +
    +
    + + {t('billing.sessionReplayAddonNextCharge')} + + + {formatBillingDate( + currentSessionReplayAddonPreview.nextChargeDate, + ) || t('billing.sessionReplayAddonNoNextCharge')} + +
    + {currentSessionReplayAddonPreview.effectiveDate ? ( +
    + + {t('billing.sessionReplayAddonEffectiveOn')} + + + {formatBillingDate( + currentSessionReplayAddonPreview.effectiveDate, + )} + +
    + ) : null} +
    + ) : ( + + {t('billing.sessionReplayAddonNoChangesHint')} + + )} +
    +
    + } + /> + { if (!isCancellingSubscription) { diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index ef6f9e7f7..e52678535 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -630,6 +630,7 @@ const SOFTWARE_APPLICATION_JSONLD = { 'Cookie-free tracking', 'Traffic web analytics', 'Session analysis', + 'Session replays', 'Website speed analytics', 'Error tracking', 'Funnels', diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index 4613ce1f8..cb7cfe956 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -6,6 +6,9 @@ import { import { getSessionsServer, + getSessionReplaysServer, + getSessionReplayServer, + deleteSessionReplayServer, getFunnelSessionsServer, getGoalSessionsServer, getErrorsServer, @@ -43,6 +46,9 @@ import { type AnalyticsParams, type AnalyticsFilter, type SessionsResponse, + type SessionReplaysResponse, + type SessionReplayResponse, + type DeleteSessionReplayResponse, type FunnelSessionsResponse, type GoalSessionsResponse, type ErrorsResponse, @@ -97,6 +103,9 @@ function formatDateForBackend(dateStr: string | undefined): string | undefined { interface ProxyRequest { action: | 'getSessions' + | 'getSessionReplays' + | 'getSessionReplay' + | 'deleteSessionReplay' | 'getErrors' | 'getFeatureFlagStats' | 'getFeatureFlagProfiles' @@ -137,6 +146,8 @@ interface ProxyRequest { goalId?: string experimentId?: string profileId?: string + psid?: string + replayId?: string errorId?: string filterType?: string filterColumn?: 'br' | 'os' @@ -215,6 +226,81 @@ export async function action({ request }: ActionFunctionArgs) { }) } + case 'getSessionReplays': { + const result = await getSessionReplaysServer( + request, + projectId, + analyticsParams, + ) + return data>({ + data: result.data, + error: result.error + ? Array.isArray(result.error) + ? result.error.join(', ') + : result.error + : null, + }) + } + + case 'getSessionReplay': { + if (!body.psid) { + return data>( + { data: null, error: 'psid is required' }, + { status: 400 }, + ) + } + + const result = await getSessionReplayServer( + request, + projectId, + body.psid, + body.replayId, + password || undefined, + ) + return data>({ + data: result.data, + error: result.error + ? Array.isArray(result.error) + ? result.error.join(', ') + : result.error + : null, + }) + } + + case 'deleteSessionReplay': { + if (!body.psid) { + return data>( + { data: null, error: 'psid is required' }, + { status: 400 }, + ) + } + + const result = await deleteSessionReplayServer( + request, + projectId, + body.psid, + body.replayId, + password || undefined, + ) + + if (result.error) { + return data>( + { + data: null, + error: Array.isArray(result.error) + ? result.error.join(', ') + : result.error, + }, + { status: 400 }, + ) + } + + return data>({ + data: result.data, + error: null, + }) + } + case 'getFunnelSessions': { const result = await getFunnelSessionsServer(request, projectId, { period: analyticsParams.period, diff --git a/web/app/routes/api.session-replay-export.ts b/web/app/routes/api.session-replay-export.ts new file mode 100644 index 000000000..9a516f0f9 --- /dev/null +++ b/web/app/routes/api.session-replay-export.ts @@ -0,0 +1,131 @@ +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + data, +} from 'react-router' + +import { + serverFetch, + streamingServerFetch, + type SessionReplayExportResponse, +} from '~/api/api.server' +import { getProjectPasswordCookie } from '~/utils/session.server' + +interface ProxyResponse { + data: T | null + error: string | null +} + +const getErrorMessage = (error: string | string[] | null) => + Array.isArray(error) ? error.join(', ') : error + +const getCookieHeaders = (cookies: string[]) => { + const headers = new Headers() + cookies.forEach((cookie) => headers.append('Set-Cookie', cookie)) + return headers +} + +export async function action({ request }: ActionFunctionArgs) { + let body: { + projectId?: string + psid?: string + replayId?: string + } + + try { + body = (await request.json()) as typeof body + } catch { + return data>( + { data: null, error: 'Invalid JSON body' }, + { status: 400 }, + ) + } + + if (!body.projectId || !body.psid) { + return data>( + { data: null, error: 'projectId and psid are required' }, + { status: 400 }, + ) + } + + const password = getProjectPasswordCookie(request, body.projectId) + const headers: Record = {} + if (password) headers['x-password'] = password + + const result = await serverFetch( + request, + 'log/session-replay/export', + { + method: 'POST', + body: { + pid: body.projectId, + psid: body.psid, + replayId: body.replayId, + }, + headers, + timeoutMs: 30000, + }, + ) + + return data>( + { + data: result.data, + error: getErrorMessage(result.error), + }, + { + status: result.status, + headers: getCookieHeaders(result.cookies), + }, + ) +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const action = url.searchParams.get('action') + const projectId = url.searchParams.get('projectId') + const exportId = url.searchParams.get('exportId') + + if (!projectId || !exportId) { + return data>( + { data: null, error: 'projectId and exportId are required' }, + { status: 400 }, + ) + } + + const password = getProjectPasswordCookie(request, projectId) + const headers: Record = {} + if (password) headers['x-password'] = password + + if (action === 'download') { + const response = await streamingServerFetch( + request, + `log/session-replay/export/${encodeURIComponent(exportId)}/download`, + { headers }, + ) + + return new Response(response.body, { + status: response.status, + headers: response.headers, + }) + } + + const result = await serverFetch( + request, + `log/session-replay/export/${encodeURIComponent(exportId)}`, + { + headers, + timeoutMs: 30000, + }, + ) + + return data>( + { + data: result.data, + error: getErrorMessage(result.error), + }, + { + status: result.status, + headers: getCookieHeaders(result.cookies), + }, + ) +} diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index fd4919200..ae6ac0a33 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -20,6 +20,7 @@ import { getPerfDataServer, getPerformanceOverallStatsServer, getSessionsServer, + getSessionReplaysServer, getSessionServer, getErrorsServer, getErrorServer, @@ -32,6 +33,7 @@ import { type PerformanceDataResponse, type PerformanceOverallObject, type SessionsResponse, + type SessionReplaysResponse, type SessionDetailsResponse, type ErrorsResponse, type ErrorDetailsResponse, @@ -349,6 +351,7 @@ export const meta: MetaFunction = ({ data, location }) => { ...(seoTabId ? { [seoTabId]: t('project.seo.title') } : {}), [PROJECT_TABS.profiles]: t('dashboard.profiles'), [PROJECT_TABS.sessions]: t('dashboard.sessions'), + [PROJECT_TABS.replays]: t('dashboard.replays'), [PROJECT_TABS.errors]: t('dashboard.errors'), [PROJECT_TABS.funnels]: t('dashboard.funnels'), [PROJECT_TABS.goals]: t('dashboard.goals'), @@ -489,6 +492,7 @@ export interface ProjectLoaderData { > | null> // Sessions data sessionsData?: Promise + replaysData?: Promise sessionDetails?: Promise // Errors data errorsData?: Promise @@ -626,6 +630,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { | Promise | null> | undefined let sessionsData: Promise | undefined + let replaysData: Promise | undefined let sessionDetails: Promise | undefined let errorsData: Promise | undefined let errorDetails: Promise | undefined @@ -733,6 +738,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { password || undefined, ).then((res) => res.data) } + } else if (tab === PROJECT_TABS.replays) { + replaysData = getSessionReplaysServer( + request, + projectId, + analyticsParams, + ).then((res) => res.data) } else if (tab === PROJECT_TABS.errors) { errorsData = getErrorsServer(request, projectId, analyticsParams).then( (res) => res.data, @@ -812,6 +823,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { perfCompareData, perfOverallCompareStats, sessionsData, + replaysData, sessionDetails, errorsData, errorDetails, diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 766570fcc..fbf889816 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -83,6 +83,7 @@ export interface ProjectSettingsActionData { password?: string transferEmail?: string email?: string + sessionReplayRetentionDays?: string } project?: Project subscriber?: Subscriber @@ -130,6 +131,9 @@ export async function action({ request, params }: ActionFunctionArgs) { const websiteUrl = formData.get('websiteUrl')?.toString() const brandKeywords = formData.get('brandKeywords')?.toString() const captchaDifficulty = formData.get('captchaDifficulty')?.toString() + const sessionReplayRetentionDays = formData + .get('sessionReplayRetentionDays') + ?.toString() const rawCaptchaDifficultyMode = formData .get('captchaDifficultyMode') ?.toString() @@ -145,7 +149,15 @@ export async function action({ request, params }: ActionFunctionArgs) { fieldErrors.name = 'Project name must be 50 characters or less' } - if (fieldErrors.name) { + if (sessionReplayRetentionDays !== undefined) { + const retention = Number(sessionReplayRetentionDays) + if (![30, 90, 365, 1825].includes(retention)) { + fieldErrors.sessionReplayRetentionDays = + 'Choose a valid session replay retention period' + } + } + + if (Object.keys(fieldErrors).length > 0) { return data( { intent, fieldErrors }, { status: 400 }, @@ -184,6 +196,10 @@ export async function action({ request, params }: ActionFunctionArgs) { updateData.captchaDifficulty = Number(captchaDifficulty) if (captchaDifficultyMode !== undefined) updateData.captchaDifficultyMode = captchaDifficultyMode + if (sessionReplayRetentionDays !== undefined) + updateData.sessionReplayRetentionDays = Number( + sessionReplayRetentionDays, + ) const result = await serverFetch(request, `project/${id}`, { method: 'PUT', diff --git a/web/app/routes/user-settings.tsx b/web/app/routes/user-settings.tsx index 33e982f47..263cadb35 100644 --- a/web/app/routes/user-settings.tsx +++ b/web/app/routes/user-settings.tsx @@ -131,6 +131,34 @@ export interface WebsiteAddonPreview { status: 'active' | 'past_due' | 'cancelled' | null } +export interface SessionReplayAddonPreview { + code: 'session_replays' + quantity: number + currentQuantity: number + pendingQuantity: number | null + billingInterval: 'monthly' | 'yearly' + currentBillingInterval: 'monthly' | 'yearly' | null + pendingBillingInterval: 'monthly' | 'yearly' | null + currency: string + dueNow: number + recurringAmount: number + nextChargeDate: string | null + effectiveDate: string | null + includedSessionReplays: number | 'custom' + totalSessionReplays: number | 'custom' + activeExtraSessionReplays: number + changeType: + | 'none' + | 'new' + | 'increase' + | 'decrease' + | 'cancel' + | 'interval_change' + | 'reactivate' + isLegacy: boolean + status: 'active' | 'past_due' | 'cancelled' | null +} + const resolveCatalogSelection = (formData: FormData) => { const rawPlanType = formData.get('planType')?.toString() const rawEventTier = formData.get('eventTier')?.toString() @@ -523,6 +551,58 @@ export async function action({ request }: ActionFunctionArgs) { ) } + case 'preview-session-replay-addon': { + const quantity = Number(formData.get('quantity')) + const billingInterval = formData.get('billingInterval')?.toString() + + const result = await serverFetch( + request, + 'user/addons/session-replays/preview', + { + method: 'POST', + body: { quantity, billingInterval }, + }, + ) + + if (result.error) { + return data( + { intent, error: result.error as string }, + { status: 400 }, + ) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-session-replay-addon': { + const quantity = Number(formData.get('quantity')) + const billingInterval = formData.get('billingInterval')?.toString() + + const result = await serverFetch( + request, + 'user/addons/session-replays', + { + method: 'PUT', + body: { quantity, billingInterval }, + }, + ) + + if (result.error) { + return data( + { intent, error: result.error as string }, + { status: 400 }, + ) + } + + return data( + { intent, success: true, user: result.data as User }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + case 'accept-project-share': { const shareId = formData.get('shareId')?.toString() diff --git a/web/app/types/dom-player.d.ts b/web/app/types/dom-player.d.ts new file mode 100644 index 000000000..44b70a536 --- /dev/null +++ b/web/app/types/dom-player.d.ts @@ -0,0 +1,6 @@ +// `dom-player` is a Vite resolve.alias to `rrweb` (see vite.config.ts). The +// neutral name keeps "rrweb" out of the served asset URLs, which privacy +// adblock lists block. Re-export rrweb's types under the alias for TS. +declare module 'dom-player' { + export * from 'rrweb' +} diff --git a/web/app/ui/Select.tsx b/web/app/ui/Select.tsx index 2db4ad435..69f5f5ad9 100644 --- a/web/app/ui/Select.tsx +++ b/web/app/ui/Select.tsx @@ -29,6 +29,7 @@ interface SelectProps { keyExtractor?: (item: T, index: number) => string iconExtractor?: (item: T, index: number) => React.ReactNode | null descriptionExtractor?: (item: T, index: number) => React.ReactNode | null + disabledItemExtractor?: (item: T, index: number) => boolean onSelect: (item: T) => void selectedItem?: T wrap?: boolean @@ -46,6 +47,7 @@ function Select({ keyExtractor, iconExtractor, descriptionExtractor, + disabledItemExtractor, onSelect, id, buttonClassName, @@ -170,6 +172,7 @@ function Select({ > {_map(items, (item, index) => { const selected = isItemSelected(item) + const disabled = disabledItemExtractor?.(item, index) || false const itemDescription = descriptionExtractor ? descriptionExtractor(item, index) : null @@ -183,13 +186,16 @@ function Select({ 'relative mx-1 cursor-pointer rounded-md pr-8 pl-3 transition-colors duration-100 ease-out select-none', hasDescriptions ? 'py-2.5' : 'py-2', { + 'cursor-not-allowed text-gray-400 dark:text-slate-600': + disabled && !selected, 'bg-gray-100 dark:bg-slate-900': focus || selected, 'text-gray-700 dark:text-gray-50': - !focus && !selected, + !focus && !selected && !disabled, 'text-gray-900 dark:text-white': focus || selected, }, ) } + disabled={disabled} value={item} > <> diff --git a/web/knip.jsonc b/web/knip.jsonc index 50e55bf64..9998be0e6 100644 --- a/web/knip.jsonc +++ b/web/knip.jsonc @@ -4,6 +4,8 @@ "@tailwindcss/forms", "@tailwindcss/typography", "tailwindcss", + "rrweb", + "dom-player", // "@swetrix/node", ], // "ignore": [ diff --git a/web/package-lock.json b/web/package-lock.json index f77aa738f..85a9be1b8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -47,6 +47,7 @@ "remix-i18next": "^7.5.0", "remix-sitemap": "^3.2.1", "remix-utils": "^9.3.1", + "rrweb": "^2.0.1", "sanitize-html": "^2.17.4", "simple-icons": "^16.21.0", "sonner": "^2.0.7", @@ -4209,6 +4210,16 @@ "win32" ] }, + "node_modules/@rrweb/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.1.tgz", + "integrity": "sha512-x8cNaKFxdvS0/5IfKOj1xG8Htnw1fUwqoPqgP61bOaWqZL6t9UtRPpO9ZlMB2z+FiTxg9zlFFL+Da4ZpFuhoiA==" + }, + "node_modules/@rrweb/utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.1.tgz", + "integrity": "sha512-t+b+4rB23/e4EpoGMdPUr6cKtDsFuOb3LxA9IHE8e3A/Xo0nMUfH0bkwqdpUNa/VGDOdiGeG3bhHjuO9zWX54g==" + }, "node_modules/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -4749,6 +4760,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -5158,6 +5175,12 @@ "node": "*" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5281,6 +5304,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8081,6 +8113,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -9290,6 +9328,37 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrdom": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.1.tgz", + "integrity": "sha512-z8GdQWFmVfa8GpcGkAqfBXseb89z0ciTPSBkID0qr0i6KHCye1MdQ4VoXFf0Ejp3ELe/c7AObu4zsbSbMT+hiw==", + "dependencies": { + "rrweb-snapshot": "^2.0.1" + } + }, + "node_modules/rrweb": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.1.tgz", + "integrity": "sha512-MQYzOtI7aZft9ffDMT6rmRSAj6hO4W6T7iA4eB9Mtcuiv3M7DbnnxDMrcPW0sQXepgYN46YhfSKe75d/mlUHrQ==", + "dependencies": { + "@rrweb/types": "^2.0.1", + "@rrweb/utils": "^2.0.1", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.0.1", + "rrweb-snapshot": "^2.0.1" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.1.tgz", + "integrity": "sha512-H8TA29ct2tpYtr5oaB0N+ksRY9bhEErfAYcKbce4+w8lqEUQChpKZd41HpBJLkp04b5QUfWUg4v6r9Okf8r7vA==", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", diff --git a/web/package.json b/web/package.json index 96ed90af9..ee38eb5ec 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,7 @@ "remix-i18next": "^7.5.0", "remix-sitemap": "^3.2.1", "remix-utils": "^9.3.1", + "rrweb": "^2.0.1", "sanitize-html": "^2.17.4", "simple-icons": "^16.21.0", "sonner": "^2.0.7", diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 3fb75a9bc..94cc70f0c 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -710,10 +710,18 @@ "privacyFirst": "Privacy-first", "featuresAlt": { "heading": "Features you need, no training required", + "webHeading": "Web analytics", + "webDescription": "Understand traffic, campaigns, events and performance from one privacy-first dashboard.", + "productHeading": "Product analytics", + "productDescription": "Investigate what people do inside your product, reproduce issues and ship better changes with confidence.", "privacy": { "title": "Privacy-first analytics", "desc": "Track visitors without cookies or personal data. Stay GDPR-compliant out of the box." }, + "traffic": { + "title": "Traffic web analytics", + "desc": "See visitors, pageviews, referrers, countries, devices and top pages without sampling." + }, "events": { "title": "Custom events & goals", "desc": "Track signups, purchases and button clicks. Set conversion goals to measure what matters." @@ -738,6 +746,14 @@ "title": "Session analysis", "desc": "Follow individual user journeys across pages. Understand how visitors navigate your site." }, + "replays": { + "title": "Session replays", + "desc": "Watch recorded sessions with timelines, clicks and page changes to understand what blocked a user." + }, + "funnels": { + "title": "Funnels", + "desc": "Track step-by-step conversion and see exactly where users leave key product flows." + }, "organisations": { "title": "Organisations", "desc": "Invite teammates, assign roles and manage shared projects from one organisation workspace." @@ -1167,6 +1183,7 @@ "profiles": "Profiles", "funnels": "Funnels", "sessions": "Sessions", + "replays": "Replays", "xOccurrences": "{{x}} occurrences", "errors": "Errors", "xPageviews": "{{x}} pageviews", @@ -1541,6 +1558,7 @@ "duplicateKeys": "Variant keys must be unique", "variantFieldsRequired": "Variant name and key are required", "start": "Start", + "play": "Play", "resume": "Resume", "pause": "Pause", "complete": "Complete", @@ -1958,7 +1976,65 @@ "sessionStartedAt": "Started", "sessionEndedAt": "Ended", "goToProfile": "Go to profile", + "watchReplay": "Watch replay", "sessionActivity": "Session activity", + "sessionReplay": { + "available": "Replay available", + "replay": "Replay", + "duration": "Replay duration: {{duration}}", + "expiresAt": "Expires {{date}}", + "eventsCount": "{{count}} replay events", + "title": "Session replay", + "loadError": "Could not load replay", + "delete": "Delete replay", + "deleted": "Replay deleted", + "deleteFailed": "Could not delete replay", + "deleteConfirmTitle": "Delete this replay?", + "deleteConfirmMessage": "This permanently deletes the session replay recording. The analytics session data stays available.", + "playerError": "Could not initialise replay player", + "empty": "No replay events were found.", + "back10": "Back 10 seconds", + "forward10": "Forward 10 seconds", + "play": "Play", + "pause": "Pause", + "settings": "Settings", + "speed": "Playback speed", + "normalSpeed": "Normal", + "screenshot": "Download screenshot", + "screenshotError": "Could not capture this replay frame. Cross-origin media can block screenshots.", + "screenshotDownloaded": "Replay screenshot downloaded", + "exportMp4": "Export to MP4", + "exportStarting": "Starting", + "exportPreparing": "Preparing MP4 export...", + "exportPreparingProgress": "Preparing MP4 export... {{progress}}%", + "exportDownloaded": "Replay MP4 downloaded", + "exportFailed": "Could not export replay to MP4", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit fullscreen", + "fullscreenError": "Could not enter fullscreen", + "showTimeline": "Show timeline", + "hideTimeline": "Hide timeline", + "timelineEvents": { + "pageview": "Pageview", + "event": "Event", + "error": "Error", + "sale": "Sale", + "refund": "Refund" + }, + "timeline": { + "title": "Timeline", + "steps": "{{count}} steps", + "empty": "No notable replay steps yet.", + "navigation": "Navigation", + "fullSnapshot": "Page snapshot", + "click": "Click", + "scroll": "Scroll", + "input": "Input", + "resize": "Viewport resize", + "mutations": "Page changes", + "mutationsCount": "{{count}} grouped changes" + } + }, "pageflow": "Page flow", "userSessions": "Sessions", "noSessions": "No sessions found for this user.", @@ -2045,6 +2121,10 @@ "noSessionsFound": "No sessions found for this period.", "exploreSessions": "View sessions", "noSessionsFiltersDesc": "No sessions match this period and filter set. Try changing the date range or filters.", + "noReplaysTitle": "No replays yet", + "noReplaysContent": "No session replays were recorded for this period. Try a different date range.", + "noReplaysFiltersTitle": "No replays found", + "noReplaysFiltersDesc": "No replays match this period and filter set. Try changing the date range or filters.", "noCaptchaEv": "No CAPTCHA events were recorded for this period.\nIf you have not set up CAPTCHA yet, see the docs to add it to your app.", "mapping": { "cc": "Country", @@ -2271,6 +2351,8 @@ "channelsDesc": "Project-scoped channels for sending alerts.", "revenue": "Revenue", "revenueDesc": "Track revenue and attribute sales to campaigns.", + "sessionReplays": "Session replays", + "sessionReplaysDesc": "Configure replay retention and project replay settings.", "import": "Import data", "importDesc": "Import analytics data from other platforms", "proxy": "Managed proxy", @@ -2310,6 +2392,22 @@ "paddleExample": "Paddle Checkout Integration:" } }, + "sessionReplays": { + "retentionTitle": "Replay retention", + "retentionLabel": "Replay retention period", + "retentionDescription": "Choose how long newly recorded session replay chunks are kept for this project.", + "retentionHint": "Retention changes apply only to newly recorded replay chunks. Existing chunks keep the expiry date they were recorded with.", + "retention": { + "30": "30 days", + "90": "90 days", + "365": "1 year", + "1825": "5 years" + }, + "upgradeRequired": "Upgrade to unlock longer replay retention.", + "upgradeTitle": "Need more replay capacity?", + "upgradeDescription": "Longer retention and higher replay capacity are available through upgraded replay entitlements.", + "upgradeCta": "Go to billing" + }, "dataImport": { "importFrom": "Import from", "importFromDesc": "Select an analytics provider to import historical data from.", @@ -2503,6 +2601,7 @@ "passwordProtection": "Password protection updated", "status": "Project status updated", "captchaDifficulty": "CAPTCHA difficulty updated", + "sessionReplayRetention": "Session replay retention updated", "organisation": "Project organisation updated", "updated": "Project setting updated" }, @@ -2893,16 +2992,21 @@ "sessionReplays": "Session replays", "sessionReplaysAddonTooltip": "Need more recorded sessions? Session replay add-ons can extend this monthly quota.", "perMonthQuota": "{{amount}} / month", + "sessionReplayUsageSummary": "{{used}} of {{total}} session replays", "recordedSessionsQuota": "Recorded sessions quota", + "addedSessionReplays": "+{{amount}} added", "api": "API", "perHourQuota": "{{amount}} / hour", "currentRateLimit": "Current rate limit", "addonsTitle": "Add-ons", "addonsDesc": "Add-ons are recurring subscription extras. They are available for active paid subscriptions.", "websiteAddonTitle": "Additional websites", - "websiteAddonDescription": "Add websites in chunks of {{amount}} at {{unitPrice}} per website per month.", + "websiteAddonDescription": "Add websites in chunks of {{amount}} to your active subscription.", "sessionReplayAddonTitle": "Additional session replays", - "sessionReplayAddonDescription": "Add {{amount}} recorded sessions per month. Session replay pricing is temporary while the feature is finalised.", + "sessionReplayAddonDescription": "Add recorded sessions in chunks of {{amount}} to your active subscription.", + "sessionReplayCapacityTitle": "Session replay capacity", + "sessionReplayCapacityDescription": "Your current plan includes {{quota}} recorded sessions per month. For higher capacity or longer retention, choose a larger Plus plan or contact us for a custom entitlement.", + "sessionReplayCapacityCta": "Review replay plans", "activeAddonAmount": "{{amount}} active", "requestAddon": "Request add-on", "comingSoon": "Coming soon", @@ -2915,6 +3019,17 @@ "legacyWebsiteAddon": "This account has a legacy website add-on. Contact support to move it to self-serve billing.", "invalidWebsiteAddonQuantity": "Choose website add-ons from 0 to 1,000 extra websites in steps of 50.", "invalidWebsiteAddonBillingInterval": "Choose monthly or yearly billing for this add-on.", + "sessionReplayAddonUpdated": "Session replay add-on updated.", + "sessionReplayAddonRequiresSubscription": "You need an active paid subscription before you can buy session replay add-ons.", + "sessionReplayAddonUnavailableDuringTrial": "Session replay add-ons become available after your trial ends and the paid subscription starts.", + "sessionReplayAddonUnavailableCancelled": "Session replay add-ons are unavailable while your subscription is scheduled for cancellation.", + "sessionReplayAddonUnavailableSuspended": "Session replay add-ons are unavailable while billing is suspended. Update your payment details first.", + "sessionReplayAddonUnavailableCustom": "This account has a custom session replay quota. Contact support to adjust it.", + "sessionReplayAddonLegacyManaged": "This account has a legacy session replay add-on. Contact support to move it to self-serve billing.", + "legacySessionReplayAddon": "This account has a legacy session replay add-on. Contact support to move it to self-serve billing.", + "invalidSessionReplayAddonQuantity": "Choose session replay add-ons from 0 to 100,000 extra recorded sessions in steps of 5,000.", + "invalidSessionReplayAddonBillingInterval": "Choose monthly or yearly billing for this add-on.", + "sessionReplayAddonChargeInProgress": "A session replay add-on charge is already in progress. Try again in a moment.", "websiteAddonUsage": "Usage", "websiteAddonIncluded": "Included", "websiteAddonPurchased": "Purchased extra", @@ -2936,7 +3051,7 @@ "websiteAddonPurchasedCount": "+{{count}} purchased", "websiteAddonActiveSummary": "You're paying {{amount}} / {{interval}} for {{count}} extra websites.", "websiteAddonManage": "Manage websites", - "websiteAddonBuy": "Buy websites", + "websiteAddonBuy": "Buy more websites", "websiteAddonNoChangesHint": "Choose a different amount to preview pricing.", "extraWebsitesAmount": "+{{amount}} websites", "noAdditionalWebsites": "No additional websites", @@ -2949,6 +3064,30 @@ "websiteAddonNextRenewal": "the next renewal", "websiteAddonPendingChange": "A scheduled add-on change will take effect on {{date}}. Select a new quantity or update the current settings to replace it.", "websiteAddonSave": "Update add-on", + "sessionReplayAddonQuantity": "Extra session replays", + "sessionReplayAddonBillingInterval": "Billing interval", + "sessionReplayAddonMonthly": "Monthly", + "sessionReplayAddonYearly": "Yearly", + "sessionReplayAddonYearlyDiscount": "Yearly billing is 10x monthly, so two months are free.", + "sessionReplayAddonYearlySavings": "2 months free", + "sessionReplayAddonYearlyOnlyNote": "Yearly add-on billing is available on yearly subscriptions, so this add-on will be billed monthly.", + "sessionReplayAddonIncludedCount": "{{count}} included", + "sessionReplayAddonPurchasedCount": "+{{count}} purchased", + "sessionReplayAddonActiveSummary": "You're paying {{amount}} / {{interval}} for {{count}} extra session replays.", + "sessionReplayAddonManage": "Manage replays", + "sessionReplayAddonBuy": "Buy more replays", + "sessionReplayAddonNoChangesHint": "Choose a different amount to preview pricing.", + "extraSessionReplaysAmount": "+{{amount}} session replays", + "noAdditionalSessionReplays": "No additional session replays", + "sessionReplayAddonDueNow": "Due now", + "sessionReplayAddonRecurring": "Recurring", + "sessionReplayAddonNextCharge": "Next charge", + "sessionReplayAddonEffectiveOn": "Effective on", + "sessionReplayAddonNoImmediateCharge": "No immediate charge", + "sessionReplayAddonNoNextCharge": "No next charge", + "sessionReplayAddonNextRenewal": "the next renewal", + "sessionReplayAddonPendingChange": "A scheduled session replay add-on change will take effect on {{date}}. Select a new quantity or update the current settings to replace it.", + "sessionReplayAddonSave": "Update add-on", "sessionReplayAddonComingSoon": "Self-serve session replay add-ons are coming soon." }, "modals": { @@ -3038,7 +3177,7 @@ }, "benefits": { "funnels": "Funnels", - "everythingFrom": "everything from {{plan}}", + "everythingFrom": "Everything from {{plan}}", "unlimitedMembers": "Unlimited members", "eventsAndGoals": "Events & Goals", "sessionUserProfileAnalysis": "Session & User profile analysis", @@ -3067,17 +3206,17 @@ "tooltips": { "advancedBotDetection": "Swetrix filters automated traffic before it is stored, from known bot user agents and headless browsers to stricter checks for suspicious headers, probe paths, referrer spam, and datacenter IPs. Blocked bot requests never count toward your event quota.", "adBlockerBypass": "Route the tracker script and analytics traffic through a managed reverse proxy on your own subdomain, so requests are served first-party from your domain.", + "sessionReplays": { + "description": "A session replay is a recorded browser session that you can play back to debug user journeys, errors, and drop-offs.", + "recording": "Replays are recorded only after you explicitly start recording through the tracker. Regular analytics events and pageviews do not create replays automatically.", + "quota": "Quota is based on recorded replays, also shown as recorded sessions, per month. It is not based on replay minutes, storage size, or how many times a replay is watched.", + "plans": "Standard includes 0 replays, Plus includes a monthly quota proportional to the selected event tier, and Enterprise is custom." + }, "plusWebsites": "Swetrix has no limit on the number of websites you can add. You can purchase more websites via add-ons. Bulk discounts are available if you contact support.", "customEventLimits": "Enterprise plans are flexible for event limits, API rate limits, and project quantity.", "manualInvoicing": "We accept all major credit & debit cards. Enterprise customers can also pay by ACH, SEPA, wire transfer, or invoice." } }, - "addons": { - "websiteTitle": "Website add-ons", - "sessionReplayTitle": "Session replay add-ons", - "websiteBundle": "+{{amount}} websites", - "sessionReplayBundle": "+{{amount}} recorded sessions" - }, "billedAnnuallyAt": "Billed annually at {{amount}}", "switchToMonthly": "Switch to monthly billing", "upToXEvents": "Up to {{amount}} monthly events.", diff --git a/web/vite.config.ts b/web/vite.config.ts index c04c2dca2..3c8656a60 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,6 +8,20 @@ export default defineConfig({ }, resolve: { tsconfigPaths: true, + alias: { + // Serve rrweb under a neutral specifier so the dev dep URL becomes + // /.vite/deps/dom-player.js (and the prod chunk is named accordingly) + // instead of containing "rrweb", which privacy adblock lists block. + 'dom-player': 'rrweb', + }, + }, + optimizeDeps: { + // rrweb is only imported inside the lazily-loaded SessionReplayModal chunk, + // so Vite's initial scan never sees it. Without this it gets discovered on + // first replay open, triggering a mid-session re-optimization that changes + // the dep hash and breaks the in-flight dynamic import ("error loading + // dynamically imported module"). Pre-bundling it on startup avoids that. + include: ['dom-player'], }, ssr: { noExternal: [