Posthog Session Replay Portable -

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.