Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ services:
- LITE_DASHBOARD=${LITE_DASHBOARD}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- OPENROUTER_MODEL=${OPENROUTER_MODEL}
# Optional: offload session replays to S3-compatible storage (AWS S3, R2, MinIO).
# When unset, replays are stored in ClickHouse. See docs/managing-your-installation.
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-}
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-}
- S3_ENDPOINT=${S3_ENDPOINT:-}
- S3_REGION=${S3_REGION:-}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE:-}
depends_on:
clickhouse:
condition: service_healthy
Expand Down
19 changes: 19 additions & 0 deletions docs/content/docs/managing-your-installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ ALTER TABLE session_replay_events MODIFY TTL toDateTime(timestamp) + INTERVAL 14
ALTER TABLE session_replay_metadata MODIFY TTL start_time + INTERVAL 14 DAY;
```

### Offload Session Replays to S3-Compatible Storage

By default, session replay event data is stored in ClickHouse. For large deployments you can offload replay batches to any S3-compatible object store (AWS S3, Cloudflare R2, MinIO, etc.), keeping only lightweight metadata in ClickHouse. Storage is enabled automatically as soon as credentials are present; when no credentials are configured, replays continue to be stored in ClickHouse.

Add the following variables to the `.env` file at the root of the repository and restart your services:

| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `S3_ACCESS_KEY_ID` | Yes | – | Access key for the bucket |
| `S3_SECRET_ACCESS_KEY` | Yes | – | Secret key for the bucket |
| `S3_BUCKET_NAME` | No | `rybbit` | Bucket name |
| `S3_ENDPOINT` | No | AWS S3 | Custom endpoint, e.g. `https://<account>.r2.cloudflarestorage.com` or `http://minio:9000`. Omit for AWS S3. |
| `S3_REGION` | No | `auto` | Bucket region |
| `S3_FORCE_PATH_STYLE` | No | `true` | Use path-style addressing. Required by most non-AWS providers; set to `false` for virtual-hosted AWS S3. |

<Callout type="info">
For backward compatibility the legacy `R2_*` variables (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ACCOUNT_ID`) are still honored as a fallback. When `R2_ACCOUNT_ID` is set and `S3_ENDPOINT` is not, the Cloudflare R2 endpoint is derived automatically. Existing R2 deployments need no changes.
</Callout>

## Anonymous Usage Telemetry

To help us improve Rybbit, self-hosted instances automatically send anonymous usage statistics to our cloud service. This includes:
Expand Down
26 changes: 13 additions & 13 deletions server/src/services/replay/sessionReplayIngestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { processResults } from "../../api/analytics/utils/utils.js";
import { parseTrackingData } from "./trackingUtils.js";
import { sessionsService } from "../sessions/sessionsService.js";
import { userIdService } from "../userId/userIdService.js";
import { r2Storage } from "../storage/r2StorageService.js";
import { objectStorage } from "../storage/s3StorageService.js";
import { siteConfig } from "../../lib/siteConfig.js";

export interface RequestMetadata {
Expand Down Expand Up @@ -48,38 +48,38 @@ export class SessionReplayIngestService {
siteId,
});

// Check if R2 storage is enabled for cloud deployments
let r2BatchKey: string | null = null;
// Offload event data to object storage when configured
let batchKey: string | null = null;
let eventDataArray: any[] = [];

if (r2Storage.isEnabled()) {
// Extract event data for R2 storage
if (objectStorage.isEnabled()) {
// Extract event data for object storage
eventDataArray = events.map(event => event.data);

try {
// Store event data batch in R2
r2BatchKey = await r2Storage.storeBatch(siteId, sessionId, eventDataArray);
// Store event data batch in object storage
batchKey = await objectStorage.storeBatch(siteId, sessionId, eventDataArray);
} catch (error) {
console.error("Failed to store in R2, falling back to ClickHouse:", error);
r2BatchKey = null;
console.error("Failed to store in object storage, falling back to ClickHouse:", error);
batchKey = null;
}
}

// Prepare events for batch insert
const eventsToInsert = events.map((event, index) => {
const serializedData = JSON.stringify(event.data);

if (r2BatchKey) {
// R2 storage: store metadata only in ClickHouse
if (batchKey) {
// Object storage: store metadata only in ClickHouse
return {
site_id: siteId,
session_id: sessionId,
user_id: userId,
identified_user_id: identifiedUserId,
timestamp: event.timestamp,
event_type: event.type,
event_data: "", // Empty string when using R2
event_data_key: r2BatchKey,
event_data: "", // Empty string when using object storage
event_data_key: batchKey,
batch_index: index,
sequence_number: index,
event_size_bytes: serializedData.length,
Expand Down
48 changes: 24 additions & 24 deletions server/src/services/replay/sessionReplayQueryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "../../types/sessionReplay.js";
import { processResults, getTimeStatement } from "../../api/analytics/utils/utils.js";
import { FilterParams } from "@rybbit/shared";
import { r2Storage } from "../storage/r2StorageService.js";
import { objectStorage } from "../storage/s3StorageService.js";
import { getFilterStatement } from "../../api/analytics/utils/getFilterStatement.js";

/**
Expand Down Expand Up @@ -169,7 +169,7 @@ export class SessionReplayQueryService {

const eventsResults = await processResults<EventRow>(eventsResult);

// Group events by batch key for efficient R2 retrieval
// Group events by batch key for efficient object-storage retrieval
const eventsByBatch = new Map<string | null, EventRow[]>();
eventsResults.forEach(event => {
const key = event.event_data_key;
Expand All @@ -182,13 +182,13 @@ export class SessionReplayQueryService {
// Process batches and reconstruct events
const events = [];

// Separate R2 and ClickHouse batches
const r2Batches: Array<[string, EventRow[]]> = [];
// Separate object-storage and ClickHouse batches
const objectStorageBatches: Array<[string, EventRow[]]> = [];
const clickhouseBatches: Array<[string | null, EventRow[]]> = [];

for (const [batchKey, batchEvents] of eventsByBatch) {
if (batchKey && r2Storage.isEnabled()) {
r2Batches.push([batchKey, batchEvents]);
if (batchKey && objectStorage.isEnabled()) {
objectStorageBatches.push([batchKey, batchEvents]);
} else {
clickhouseBatches.push([batchKey, batchEvents]);
}
Expand All @@ -205,29 +205,29 @@ export class SessionReplayQueryService {
}
}

// Fetch R2 batches in parallel (with increased concurrency for better throughput)
// Fetch object-storage batches in parallel (with increased concurrency for better throughput)
const PARALLEL_BATCH_SIZE = 50;
const r2Results: Array<{ batchKey: string; batchEvents: EventRow[]; data: any[] | null }> = [];
const objectStorageResults: Array<{ batchKey: string; batchEvents: EventRow[]; data: any[] | null }> = [];

for (let i = 0; i < r2Batches.length; i += PARALLEL_BATCH_SIZE) {
const batchSlice = r2Batches.slice(i, i + PARALLEL_BATCH_SIZE);
for (let i = 0; i < objectStorageBatches.length; i += PARALLEL_BATCH_SIZE) {
const batchSlice = objectStorageBatches.slice(i, i + PARALLEL_BATCH_SIZE);

const promises = batchSlice.map(async ([batchKey, batchEvents]) => {
try {
const eventDataArray = await r2Storage.getBatch(batchKey);
const eventDataArray = await objectStorage.getBatch(batchKey);
return { batchKey, batchEvents, data: eventDataArray };
} catch (error) {
console.error(`Failed to fetch R2 batch ${batchKey}:`, error);
console.error(`Failed to fetch object-storage batch ${batchKey}:`, error);
return { batchKey, batchEvents, data: null };
}
});

const results = await Promise.all(promises);
r2Results.push(...results);
objectStorageResults.push(...results);
}

// Process R2 results
for (const { batchEvents, data } of r2Results) {
// Process object-storage results
for (const { batchEvents, data } of objectStorageResults) {
if (data) {
for (const event of batchEvents) {
if (event.batch_index !== null && data[event.batch_index]) {
Expand Down Expand Up @@ -273,13 +273,13 @@ export class SessionReplayQueryService {
* This includes:
* - Events from session_replay_events table
* - Metadata from session_replay_metadata table
* - R2 stored data (if enabled)
* - Object-storage data (if enabled)
*/
async deleteSessionReplay(siteId: number, sessionId: string): Promise<void> {
// First, get all R2 keys for this session if R2 is enabled
if (r2Storage.isEnabled()) {
// First, get all object-storage keys for this session if storage is enabled
if (objectStorage.isEnabled()) {
try {
const r2KeysResult = await clickhouse.query({
const storageKeysResult = await clickhouse.query({
query: `
SELECT DISTINCT event_data_key
FROM session_replay_events
Expand All @@ -291,13 +291,13 @@ export class SessionReplayQueryService {
format: "JSONEachRow",
});

const r2Keys = await processResults<{ event_data_key: string }>(r2KeysResult);
const storageKeys = await processResults<{ event_data_key: string }>(storageKeysResult);

// Delete all R2 batches in parallel
await Promise.all(r2Keys.map(row => r2Storage.deleteBatch(row.event_data_key)));
// Delete all object-storage batches in parallel
await Promise.all(storageKeys.map(row => objectStorage.deleteBatch(row.event_data_key)));
} catch (error) {
console.error(`Failed to delete R2 data for session ${sessionId}:`, error);
// Continue with ClickHouse deletion even if R2 fails
console.error(`Failed to delete object-storage data for session ${sessionId}:`, error);
// Continue with ClickHouse deletion even if object storage fails
}
}

Expand Down
Loading