Because PostHog is open source (MIT licensed), you can download the entire application stack and run it on your own AWS or GCP buckets. If you run posthog-js (the web SDK), the session recordings are stored on your infrastructure.
// session-recorder.ts
interface SessionEvent
type: string;
timestamp: number;
data: any;
interface SessionRecording
sessionId: string;
userId: string;
startTime: number;
endTime?: number;
events: SessionEvent[];
metadata:
userAgent: string;
viewport: width: number; height: number ;
url: string;
;
class PortableSessionRecorder {
private recording: SessionRecording | null = null;
private eventBuffer: SessionEvent[] = [];
private isRecording = false;
private flushInterval: NodeJS.Timeout | null = null;
constructor(
private config: 'localstorage';
= {}
) 'memory';
start(userId?: string): void
if (this.isRecording) return;
this.recording = 'anonymous',
startTime: Date.now(),
events: [],
metadata:
userAgent: navigator.userAgent,
viewport:
width: window.innerWidth,
height: window.innerHeight,
,
url: window.location.href,
,
;
this.isRecording = true;
this.setupEventListeners();
this.startFlushInterval();
this.captureInitialState();
stop(): SessionRecording | null
if (!this.isRecording
private setupEventListeners(): void
// Mouse events
document.addEventListener('click', this.handleClick);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('scroll', this.handleScroll);
// Form events
document.addEventListener('input', this.handleInput);
document.addEventListener('submit', this.handleSubmit);
// Navigation events
window.addEventListener('popstate', this.handleNavigation);
// Console events
this.interceptConsole();
// Error events
window.addEventListener('error', this.handleError);
window.addEventListener('unhandledrejection', this.handlePromiseError);
private handleClick = (event: MouseEvent): void =>
const target = event.target as HTMLElement;
this.addEvent('click',
x: event.clientX,
y: event.clientY,
target: this.getElementPath(target),
text: target.innerText?.substring(0, 100),
tagName: target.tagName,
);
;
private handleMouseMove = (event: MouseEvent): void =>
// Throttle mousemove events
if (this.shouldThrottle('mousemove', 50)) return;
this.addEvent('mousemove',
x: event.clientX,
y: event.clientY,
);
;
private handleScroll = (): void =>
if (this.shouldThrottle('scroll', 100)) return; posthog session replay portable
this.addEvent('scroll',
scrollX: window.scrollX,
scrollY: window.scrollY,
);
;
private handleInput = (event: Event): void =>
const target = event.target as HTMLInputElement;
this.addEvent('input',
target: this.getElementPath(target),
value: target.type === 'password' ? '[REDACTED]' : target.value,
tagName: target.tagName,
inputType: target.type,
);
;
private handleSubmit = (event: Event): void =>
const target = event.target as HTMLFormElement;
this.addEvent('submit',
target: this.getElementPath(target),
formData: this.sanitizeFormData(new FormData(target)),
);
;
private handleNavigation = (): void =>
this.addEvent('navigation',
url: window.location.href,
state: history.state,
);
;
private interceptConsole(): void
const originalConsole = ...console ;
const logTypes = ['log', 'info', 'warn', 'error'] as const;
logTypes.forEach(type =>
console[type] = (...args: any[]) =>
this.addEvent('console',
type,
args: this.sanitizeConsoleArgs(args),
);
originalConsole[type].apply(console, args);
;
);
private handleError = (event: ErrorEvent): void =>
this.addEvent('error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
);
;
private handlePromiseError = (event: PromiseRejectionEvent): void =>
this.addEvent('promise_error',
reason: String(event.reason),
stack: event.reason?.stack,
);
;
private captureInitialState(): void
this.addEvent('initial_state',
url: window.location.href,
viewport:
width: window.innerWidth,
height: window.innerHeight,
,
scroll:
x: window.scrollX,
y: window.scrollY,
,
domSnapshot: this.captureDomSnapshot(),
);
private captureDomSnapshot(): any
// Capture simplified DOM structure
const captureElement = (element: Element, depth = 0): any =>
if (depth > 5) return truncated: true ;
return undefined,
children: Array.from(element.children)
.slice(0, 10)
.map(child => captureElement(child, depth + 1)),
;
;
return captureElement(document.body);
private addEvent(type: string, data: any): void
if (!this.isRecording) return; Because PostHog is open source (MIT licensed), you
const event: SessionEvent =
type,
timestamp: Date.now(),
data,
;
this.eventBuffer.push(event);
if (this.eventBuffer.length >= (this.config.maxBufferSize
private flushEvents(): void this.eventBuffer.length === 0) return;
this.recording.events.push(...this.eventBuffer);
this.eventBuffer = [];
// Persist to storage if configured
this.persistRecording();
private async persistRecording(): Promise<void>
if (!this.recording) return;
switch (this.config.storage)
case 'localstorage':
localStorage.setItem(
`session_$this.recording.sessionId`,
JSON.stringify(this.recording)
);
break;
case 'indexeddb':
await this.saveToIndexedDB(this.recording);
break;
case 'memory':
// Keep in memory only
break;
private async saveToIndexedDB(recording: SessionRecording): Promise<void>
// Implement IndexedDB storage
const db = await this.openIndexedDB();
const transaction = db.transaction(['sessions'], 'readwrite');
const store = transaction.objectStore('sessions');
store.put(recording);
private openIndexedDB(): Promise<IDBDatabase>
return new Promise((resolve, reject) =>
const request = indexedDB.open('SessionRecorder', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) =>
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('sessions'))
db.createObjectStore('sessions', keyPath: 'sessionId' );
;
);
private startFlushInterval(): void
this.flushInterval = setInterval(() =>
this.flushEvents();
, this.config.flushIntervalMs);
private getElementPath(element: HTMLElement): string
const path: string[] = [];
let current: HTMLElement
private sanitizeFormData(formData: FormData): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of formData.entries())
// Redact sensitive fields
const lowerKey = key.toLowerCase();
if (lowerKey.includes('password')
return sanitized;
}
private sanitizeConsoleArgs(args: any[]): any[]
return args.map(arg =>
if (typeof arg === 'string' && arg.length > 500)
return arg.substring(0, 500) + '... [TRUNCATED]';
if (arg instanceof Error)
return
message: arg.message,
stack: arg.stack?.substring(0, 1000),
;
return arg;
);
start(userId
private throttleTimestamps: Record<string, number> = {};
private shouldThrottle(eventType: string, minIntervalMs: number): boolean
private generateSessionId(): string
return $Date.now()-$Math.random().toString(36).substring(2, 15);
export(): string
if (!this.recording) return '';
this.flushEvents();
return JSON.stringify(this.recording);
destroy(): void
this.stop();
// Remove event listeners
document.removeEventListener('click', this.handleClick);
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('scroll', this.handleScroll);
document.removeEventListener('input', this.handleInput);
document.removeEventListener('submit', this.handleSubmit);
window.removeEventListener('popstate', this.handleNavigation);
window.removeEventListener('error', this.handleError);
window.removeEventListener('unhandledrejection', this.handlePromiseError);
}
| Feature | Hotjar / FullStory | LogRocket | PostHog (Portable) |
| :--- | :--- | :--- | :--- |
| Data Format | Proprietary Binary / Video | Proprietary Binary | Open JSON (DOM Snapshots) |
| Self-Hosting | No | Limited (Enterprise only) | Yes (MIT Open Source) |
| Export to Warehouse | Rows (aggregated) | API limits | Real-time Stream (All raw data) |
| Delete via API | Partial | Yes | Full CRUD access |
| Run ML on data | Not possible (no raw access) | Very difficult | Native (Export to Colab/Jupyter) |
Session replay is invaluable for debugging UX issues—but it’s often locked inside vendor silos, making data migration, compliance audits, or custom analysis a nightmare. PostHog’s approach stands out because it’s built portable from the start.
Let's get technical. How do you actually achieve portability? You have two primary paths: Self-Hosting with Object Storage, or the Batch Export API.
PostHog stores replays as a series of "snapshots" (JSON data representing DOM mutations, mouse movements, inputs, etc.). You can export this raw data via the API.
| Feature | PostHog Cloud (SaaS) | Portable (Self-Host) |
|---------|----------------------|----------------------|
| Data residency | EU/US only | Anywhere you deploy |
| Export raw replays | Limited (via API) | Full database access |
| Maintenance | None | Team handles upgrades |
| Cost | Free tier + usage | Infrastructure + support (optional) |
Portability doesn’t mean “zero effort.” Self-hosted replay storage requires managing ClickHouse (PostHog’s underlying DB) and blob storage. But compared to closed SaaS where you can’t even see your own raw replay events, PostHog’s portability is a step change.