use anyhow::Context;
use chrono::{DateTime, Utc};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use url::Url;
use uuid::Uuid;
pub const BROWSER_RECORDING_SCHEMA: &str = "cephas.browser-recording.v1";
pub const DEFAULT_MAX_RECORDING_EVENTS: usize = 10_000;
pub const DEFAULT_MAX_RECORDING_FRAMES: usize = 1_200;
pub const DEFAULT_MAX_RECORDING_DIRECTORIES: usize = 25;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrowserRecordingEventKind {
Started,
Stopped,
BrowserAction,
Navigation,
Snapshot,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserRecordingFrame {
pub frame_id: Uuid,
pub index: usize,
pub file_name: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BrowserRecordingEvent {
pub schema: &'static str,
pub recording_id: Uuid,
pub event_id: Uuid,
pub sequence: u64,
pub occurred_at: DateTime<Utc>,
pub kind: BrowserRecordingEventKind,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_id: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frame: Option<BrowserRecordingFrame>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserRecordingManifest {
pub schema: &'static str,
pub recording_id: Uuid,
pub title: String,
pub started_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
pub event_count: usize,
pub frame_count: usize,
pub max_events: usize,
pub max_frames: usize,
pub events_file: String,
pub frames_dir: String,
pub replay_file: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingFrame {
pub frame: BrowserRecordingFrame,
path: PathBuf,
}
impl PendingFrame {
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FinishedRecording {
pub recording_id: Uuid,
pub directory: PathBuf,
pub manifest_path: PathBuf,
pub replay_path: PathBuf,
pub event_count: usize,
pub frame_count: usize,
}
struct RecordingEventDraft {
kind: BrowserRecordingEventKind,
message: String,
tab_id: Option<usize>,
url: Option<Url>,
title: Option<String>,
frame: Option<BrowserRecordingFrame>,
details: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct BrowserRecordingStore {
root: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrowserRecordingDiagnostics {
pub directories: usize,
pub max_directories: usize,
pub total_bytes: u64,
}
impl BrowserRecordingStore {
pub fn new(root: Option<PathBuf>) -> anyhow::Result<Self> {
let root = match root {
Some(root) => root,
None => default_recording_root()?,
};
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn start(&self, title: impl Into<String>) -> anyhow::Result<BrowserRecorder> {
let recorder = BrowserRecorder::start_in(
&self.root,
title,
DEFAULT_MAX_RECORDING_EVENTS,
DEFAULT_MAX_RECORDING_FRAMES,
)?;
prune_recording_directories(
&self.root,
DEFAULT_MAX_RECORDING_DIRECTORIES,
Some(recorder.directory()),
)?;
Ok(recorder)
}
pub fn prune_old_recordings(&self) -> anyhow::Result<usize> {
prune_recording_directories(&self.root, DEFAULT_MAX_RECORDING_DIRECTORIES, None)
}
pub fn diagnostics(&self) -> anyhow::Result<BrowserRecordingDiagnostics> {
let directories = recording_directories(&self.root)?;
let total_bytes =
directories
.iter()
.try_fold(0_u64, |total, directory| -> anyhow::Result<u64> {
Ok(total.saturating_add(directory_size(directory)?))
})?;
Ok(BrowserRecordingDiagnostics {
directories: directories.len(),
max_directories: DEFAULT_MAX_RECORDING_DIRECTORIES,
total_bytes,
})
}
}
#[derive(Debug, Clone)]
pub struct BrowserRecorder {
manifest: BrowserRecordingManifest,
directory: PathBuf,
events_path: PathBuf,
frames_dir: PathBuf,
replay_path: PathBuf,
manifest_path: PathBuf,
next_sequence: u64,
events: Vec<BrowserRecordingEvent>,
}
impl BrowserRecorder {
pub fn start_in(
root: &Path,
title: impl Into<String>,
max_events: usize,
max_frames: usize,
) -> anyhow::Result<Self> {
let recording_id = Uuid::now_v7();
let started_at = Utc::now();
let directory_name = format!("{}-{recording_id}", started_at.format("%Y%m%dT%H%M%SZ"));
let directory = root.join(directory_name);
let frames_dir = directory.join("frames");
fs::create_dir_all(&frames_dir).with_context(|| {
format!(
"failed to create recording directory {}",
frames_dir.display()
)
})?;
let events_path = directory.join("events.jsonl");
let replay_path = directory.join("index.html");
let manifest_path = directory.join("recording.json");
let manifest = BrowserRecordingManifest {
schema: BROWSER_RECORDING_SCHEMA,
recording_id,
title: title.into(),
started_at,
ended_at: None,
event_count: 0,
frame_count: 0,
max_events: max_events.max(1),
max_frames: max_frames.max(1),
events_file: "events.jsonl".to_string(),
frames_dir: "frames".to_string(),
replay_file: "index.html".to_string(),
};
let mut recorder = Self {
manifest,
directory,
events_path,
frames_dir,
replay_path,
manifest_path,
next_sequence: 1,
events: Vec::new(),
};
recorder.record_event(
BrowserRecordingEventKind::Started,
"recording started",
None,
None,
None,
None,
)?;
Ok(recorder)
}
pub fn recording_id(&self) -> Uuid {
self.manifest.recording_id
}
pub fn directory(&self) -> &Path {
&self.directory
}
pub fn frame_count(&self) -> usize {
self.manifest.frame_count
}
pub fn max_frames(&self) -> usize {
self.manifest.max_frames
}
pub fn frame_limit_reached(&self) -> bool {
self.manifest.frame_count >= self.manifest.max_frames
}
pub fn event_count(&self) -> usize {
self.manifest.event_count
}
pub fn max_events(&self) -> usize {
self.manifest.max_events
}
pub fn reserve_frame(&mut self, reason: impl Into<String>) -> Option<PendingFrame> {
if self.manifest.frame_count >= self.manifest.max_frames {
return None;
}
let frame_id = Uuid::now_v7();
let index = self.manifest.frame_count + 1;
let file_name = format!("{index:06}-{frame_id}.png");
Some(PendingFrame {
path: self.frames_dir.join(&file_name),
frame: BrowserRecordingFrame {
frame_id,
index,
file_name,
reason: reason.into(),
},
})
}
pub fn record_frame(
&mut self,
pending: PendingFrame,
tab_id: Option<usize>,
url: Option<Url>,
title: Option<String>,
) -> anyhow::Result<BrowserRecordingEvent> {
self.manifest.frame_count += 1;
self.record_event_with_frame(RecordingEventDraft {
kind: BrowserRecordingEventKind::Snapshot,
message: format!("snapshot captured: {}", pending.frame.reason),
tab_id,
url,
title,
frame: Some(pending.frame),
details: None,
})
}
pub fn record_event(
&mut self,
kind: BrowserRecordingEventKind,
message: impl Into<String>,
tab_id: Option<usize>,
url: Option<Url>,
title: Option<String>,
details: Option<Value>,
) -> anyhow::Result<BrowserRecordingEvent> {
self.record_event_with_frame(RecordingEventDraft {
kind,
message: message.into(),
tab_id,
url,
title,
frame: None,
details,
})
}
pub fn record_error(
&mut self,
message: impl Into<String>,
tab_id: Option<usize>,
url: Option<Url>,
title: Option<String>,
details: Option<Value>,
) -> anyhow::Result<BrowserRecordingEvent> {
self.record_event(
BrowserRecordingEventKind::Error,
message,
tab_id,
url,
title,
details,
)
}
pub fn finish(mut self) -> anyhow::Result<FinishedRecording> {
let ended_at = Utc::now();
self.manifest.ended_at = Some(ended_at);
self.record_event(
BrowserRecordingEventKind::Stopped,
"recording stopped",
None,
None,
None,
None,
)?;
self.write_manifest()?;
self.write_replay()?;
Ok(FinishedRecording {
recording_id: self.manifest.recording_id,
directory: self.directory,
manifest_path: self.manifest_path,
replay_path: self.replay_path,
event_count: self.manifest.event_count,
frame_count: self.manifest.frame_count,
})
}
fn record_event_with_frame(
&mut self,
draft: RecordingEventDraft,
) -> anyhow::Result<BrowserRecordingEvent> {
if self.manifest.event_count >= self.manifest.max_events {
return Ok(self
.events
.last()
.cloned()
.unwrap_or_else(|| BrowserRecordingEvent {
schema: BROWSER_RECORDING_SCHEMA,
recording_id: self.manifest.recording_id,
event_id: Uuid::now_v7(),
sequence: self.next_sequence,
occurred_at: Utc::now(),
kind: BrowserRecordingEventKind::Error,
message: "recording event limit reached".to_string(),
tab_id: None,
url: None,
title: None,
frame: None,
details: None,
}));
}
let event = BrowserRecordingEvent {
schema: BROWSER_RECORDING_SCHEMA,
recording_id: self.manifest.recording_id,
event_id: Uuid::now_v7(),
sequence: self.next_sequence,
occurred_at: Utc::now(),
kind: draft.kind,
message: draft.message,
tab_id: draft.tab_id,
url: draft.url,
title: draft.title,
frame: draft.frame,
details: draft.details,
};
self.next_sequence += 1;
self.manifest.event_count += 1;
append_json_line(&self.events_path, &event)?;
self.events.push(event.clone());
Ok(event)
}
fn write_manifest(&self) -> anyhow::Result<()> {
let data = serde_json::to_string_pretty(&self.manifest)?;
fs::write(&self.manifest_path, data).with_context(|| {
format!(
"failed to write recording manifest {}",
self.manifest_path.display()
)
})
}
fn write_replay(&self) -> anyhow::Result<()> {
fs::write(&self.replay_path, replay_html(&self.manifest, &self.events)).with_context(|| {
format!(
"failed to write recording replay {}",
self.replay_path.display()
)
})
}
}
fn append_json_line<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create recording directory {}", parent.display())
})?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open recording event log {}", path.display()))?;
serde_json::to_writer(&mut file, value).context("failed to serialize recording event")?;
file.write_all(b"\n")
.context("failed to write recording event newline")
}
fn default_recording_root() -> anyhow::Result<PathBuf> {
let dirs = ProjectDirs::from("dev", "Cephas", "Cephas")
.context("could not determine platform recording directory")?;
Ok(dirs.data_local_dir().join("recordings"))
}
fn recording_directories(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
if !root.exists() {
return Ok(Vec::new());
}
let mut directories = Vec::new();
for entry in fs::read_dir(root)
.with_context(|| format!("failed to read recording root {}", root.display()))?
{
let entry = entry?;
if entry.file_type()?.is_dir() {
directories.push(entry.path());
}
}
directories.sort();
Ok(directories)
}
fn prune_recording_directories(
root: &Path,
max_directories: usize,
preserve: Option<&Path>,
) -> anyhow::Result<usize> {
let max_directories = max_directories.max(1);
let mut directories = recording_directories(root)?;
let mut removed = 0;
while directories.len() > max_directories {
let Some(index) = directories
.iter()
.position(|directory| preserve != Some(directory.as_path()))
else {
break;
};
let directory = directories.remove(index);
fs::remove_dir_all(&directory)
.with_context(|| format!("failed to remove old recording {}", directory.display()))?;
removed += 1;
}
Ok(removed)
}
fn directory_size(path: &Path) -> anyhow::Result<u64> {
let metadata = fs::symlink_metadata(path)
.with_context(|| format!("failed to inspect recording path {}", path.display()))?;
if metadata.file_type().is_symlink() {
return Ok(0);
}
if metadata.is_file() {
return Ok(metadata.len());
}
if !metadata.is_dir() {
return Ok(0);
}
let mut total = 0_u64;
for entry in fs::read_dir(path)
.with_context(|| format!("failed to read recording directory {}", path.display()))?
{
let entry = entry?;
total = total.saturating_add(directory_size(&entry.path())?);
}
Ok(total)
}
fn replay_html(manifest: &BrowserRecordingManifest, events: &[BrowserRecordingEvent]) -> String {
let title = html_escape::encode_text(&manifest.title);
let mut timeline = String::new();
for event in events {
let message = html_escape::encode_text(&event.message);
let kind_raw = format!("{:?}", event.kind);
let kind = html_escape::encode_text(&kind_raw);
let url = event
.url
.as_ref()
.map(|url| html_escape::encode_text(url.as_str()).to_string())
.unwrap_or_default();
let frame = event.frame.as_ref().map(|frame| {
let src_raw = format!("frames/{}", frame.file_name);
let src = html_escape::encode_double_quoted_attribute(&src_raw);
format!(r#"<a href="{src}"><img src="{src}" loading="lazy" alt="Recorded browser frame"></a>"#)
}).unwrap_or_default();
timeline.push_str(&format!(
r#"<article class="event"><div class="meta"><span>{seq:04}</span><span>{kind}</span><span>{time}</span></div><h2>{message}</h2><p>{url}</p>{frame}</article>"#,
seq = event.sequence,
time = event.occurred_at.to_rfc3339(),
));
}
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Cephas Recording - {title}</title><style>body{{margin:0;background:#0b1020;color:#e5eefc;font:15px/1.55 Inter,system-ui,sans-serif}}main{{width:min(1160px,calc(100vw - 28px));margin:0 auto;padding:34px 0 70px}}h1{{margin:0 0 8px;font-size:clamp(34px,6vw,64px);letter-spacing:-.06em}}.summary{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:22px 0}}.tile,.event{{border:1px solid rgba(148,163,184,.22);border-radius:18px;background:rgba(15,23,42,.82);box-shadow:0 20px 60px rgba(0,0,0,.22)}}.tile{{padding:16px}}.tile strong{{display:block;font-size:26px}}.tile span,.meta,p{{color:#9fb3cc}}.event{{overflow:hidden;margin:14px 0;padding:16px}}.meta{{display:flex;gap:12px;flex-wrap:wrap;font:800 12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}}h2{{margin:10px 0 6px;font-size:19px}}img{{display:block;width:100%;height:auto;margin-top:14px;border-radius:14px;border:1px solid rgba(148,163,184,.2)}}a{{color:inherit}}@media(max-width:760px){{.summary{{grid-template-columns:1fr 1fr}}main{{padding-top:24px}}}}</style></head><body><main><h1>{title}</h1><p>Visual browser recording generated by Cephas. Frames are WebKit visible-region snapshots; events are ordered JSONL records.</p><section class="summary"><div class="tile"><strong>{events}</strong><span>Events</span></div><div class="tile"><strong>{frames}</strong><span>Frames</span></div><div class="tile"><strong>{started}</strong><span>Started</span></div><div class="tile"><strong>{ended}</strong><span>Ended</span></div></section>{timeline}</main></body></html>"#,
events = manifest.event_count,
frames = manifest.frame_count,
started = manifest.started_at.format("%H:%M:%S"),
ended = manifest
.ended_at
.map(|ended| ended.format("%H:%M:%S").to_string())
.unwrap_or_else(|| "active".to_string()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_writes_events_manifest_and_replay() {
let dir = tempfile::tempdir().unwrap();
let store = BrowserRecordingStore::new(Some(dir.path().join("recordings"))).unwrap();
let mut recorder = store.start("Test recording").unwrap();
assert_eq!(recorder.event_count(), 1);
assert_eq!(recorder.recording_id().get_version_num(), 7);
let page = Url::parse("https://example.com/").unwrap();
recorder
.record_event(
BrowserRecordingEventKind::Navigation,
"loaded page",
Some(1),
Some(page.clone()),
Some("Example".to_string()),
None,
)
.unwrap();
let frame = recorder.reserve_frame("test").unwrap();
fs::write(frame.path(), b"not a real png").unwrap();
recorder
.record_frame(frame, Some(1), Some(page), Some("Example".to_string()))
.unwrap();
let finished = recorder.finish().unwrap();
assert_eq!(finished.frame_count, 1);
assert!(finished.manifest_path.exists());
assert!(finished.replay_path.exists());
let events = fs::read_to_string(finished.directory.join("events.jsonl")).unwrap();
assert_eq!(events.lines().count(), 4);
let replay = fs::read_to_string(finished.replay_path).unwrap();
assert!(replay.contains("Test recording"));
assert!(replay.contains("loaded page"));
}
#[test]
fn recording_respects_frame_limit() {
let dir = tempfile::tempdir().unwrap();
let mut recorder = BrowserRecorder::start_in(dir.path(), "Limited", 8, 1).unwrap();
let frame = recorder.reserve_frame("first").unwrap();
fs::write(frame.path(), b"fake").unwrap();
recorder.record_frame(frame, None, None, None).unwrap();
assert!(recorder.reserve_frame("second").is_none());
assert!(recorder.frame_limit_reached());
assert_eq!(recorder.max_frames(), 1);
assert_eq!(recorder.max_events(), 8);
}
#[test]
fn recording_store_prunes_old_directories() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("recordings");
for name in ["001-old", "002-old", "003-new"] {
fs::create_dir_all(root.join(name)).unwrap();
}
let removed = prune_recording_directories(&root, 2, None).unwrap();
assert_eq!(removed, 1);
assert!(!root.join("001-old").exists());
assert_eq!(recording_directories(&root).unwrap().len(), 2);
let store = BrowserRecordingStore::new(Some(root)).unwrap();
let diagnostics = store.diagnostics().unwrap();
assert_eq!(diagnostics.directories, 2);
assert_eq!(
diagnostics.max_directories,
DEFAULT_MAX_RECORDING_DIRECTORIES
);
}
}