An edge observability platform for mobile applications. Focal Lens collects telemetry batches from devices via a Rust ingestion proxy, stores them in PostgreSQL, and surfaces them in a Next.js control-plane dashboard.
- Architecture
- Features
- Tech Stack
- Prerequisites
- Quick Start (Docker)
- Local Development
- Seed Data
- Configuration
- API Reference
- Screenshots
- Dashboard Pages
- Project Structure
- Troubleshooting
┌─────────────────┐ TelemetryBatch ┌──────────────────┐
│ Mobile SDK │ ──── proto/gzip POST ───▶ │ focal-proxy │
│ (iOS/Android) │ │ (axum / Rust) │
└─────────────────┘ └────────┬─────────┘
│
sqlx async writes
│
┌────────▼─────────┐
│ PostgreSQL 16 │
│ batches, events │
│ workflow_bundles │
└────────┬─────────┘
│
┌────────▼─────────┐
│ Dashboard │
│ (Next.js 14) │
└──────────────────┘
▲ served on :3000
│ /api/proxy/* → :8080
The proxy exposes:
POST /v1/flush— ingests gzip+proto or raw proto batchesGET/PUT /v1/workflows[/:app_id]— workflow bundle CRUDGET /v1/batches— paginated batch listGET /v1/batches/:id/events— events from a single batchGET /v1/stats— aggregated metricsGET /v1/health— liveness probe
| Feature | Description |
|---|---|
| Batch ingestion | Accepts gzip-compressed protobuf TelemetryBatch payloads with SHA-256 checksum validation |
| Event log | Stores individual telemetry events (log, network, interaction, lifecycle) with JSONB extra fields |
| Network inspector | Filters HTTP request events by method/URL within any batch |
| Workflow bundles | Push remote configuration bundles to devices; conditional GET with ETag support |
| Control-plane dashboard | GitHub-dark UI with KPI cards, flush-reason donut chart, event browser, network inspector, workflow editor |
| PostgreSQL backend | Relational storage with indexes; falls back to local disk when DATABASE_URL is unset |
| Dual storage | PgStorage for production (Docker), LocalDiskStorage for tests and offline dev |
| Docker Compose | One-command full-stack: Postgres + proxy + dashboard + seed |
| Graceful shutdown | SIGTERM/Ctrl-C drains in-flight requests before exit |
| Layer | Technology |
|---|---|
| Ingestion proxy | Rust 1.82, axum 0.7, tokio 1.x, prost 0.13 |
| Database | PostgreSQL 16 with JSONB for event extras |
| ORM/driver | sqlx 0.8 (async, runtime query mode — no compile-time DB required) |
| Dashboard | Next.js 14 (App Router), React 18, TypeScript |
| UI components | shadcn/ui (Radix UI primitives) + Tailwind CSS |
| Data fetching | TanStack Query v5 |
| Charts | Recharts |
| Containerisation | Docker multi-stage builds, Docker Compose v2 |
| Proto codegen | prost-build + protoc |
| Mobile FFI | uniffi 0.28 (Swift + Kotlin bindings) |
- Docker Engine ≥ 24
- Docker Compose v2 (
docker composenotdocker-compose)
- Rust 1.82+ (
rustup update stable) - Node.js 20+ (
node --version) - protoc ≥ 3.21 (
brew install protobufon macOS) - PostgreSQL 16 (optional — proxy falls back to local disk without it)
# Clone
git clone https://github.com/focal-lens/focal-lens.git
cd focal-lens
# Build images and start all services (first run takes ~5 minutes)
docker compose up --build
# In a separate terminal, seed realistic data
docker compose run --rm seed
# Open the dashboard
open http://localhost:3000Services:
| Service | URL |
|---|---|
| Dashboard | http://localhost:3000 |
| focal-proxy API | http://localhost:8080 |
| PostgreSQL | localhost:5432 |
To stop everything:
docker compose down # keep DB volume
docker compose down -v # also delete DB volume (full reset)# Using Docker for just the DB
docker run -d --name focal_pg \
-e POSTGRES_DB=focal_lens \
-e POSTGRES_USER=focal \
-e POSTGRES_PASSWORD=focal_secret \
-p 5432:5432 \
postgres:16-alpine
# Or use an existing local Postgres instance
createdb focal_lens# With PostgreSQL
DATABASE_URL=postgres://focal:focal_secret@localhost:5432/focal_lens \
FOCAL_REQUIRE_CHECKSUM=false \
RUST_LOG=focal_proxy=debug,tower_http=debug \
cargo run -p focal-proxy
# Without PostgreSQL (flat-file fallback)
FOCAL_STORAGE_DIR=/tmp/focal-proxy-data \
FOCAL_REQUIRE_CHECKSUM=false \
cargo run -p focal-proxyMigrations run automatically on startup when DATABASE_URL is set.
# Seed into PostgreSQL
PGPASSWORD=focal_secret psql -h localhost -U focal -d focal_lens -f scripts/seed.sql
# Or seed flat-file storage
FOCAL_STORAGE_DIR=/tmp/focal-proxy-data python3 scripts/seed.py 50cd dashboard
npm install
npm run devOpen http://localhost:3000 — the dashboard proxies API calls to http://localhost:8080 via Next.js rewrites.
scripts/seed.sql inserts:
- 15 batches across 5 devices (iPhone 15 Pro, Pixel 8 Pro, Galaxy S24, iPhone 14, OnePlus 12)
- Flush reasons: threshold, manual, periodic, crash, sdk_stop
- 100+ events covering all four kinds:
log— trace/debug/info/warn/error/fatal messages with structured attributesnetwork— HTTP requests with method, URL, status code, duration, and byte countsinteraction— user taps/swipes with element IDs and screen nameslifecycle— app_start, app_foreground, app_background, app_crash
- 1 workflow bundle placeholder in the
workflow_bundlestable
scripts/seed.py generates randomised flat-file seed data (useful when running without PostgreSQL):
python3 scripts/seed.py 100 # seed 100 random batchesAll configuration is via environment variables (no config file needed).
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(unset) | PostgreSQL DSN. When set, uses PgStorage; otherwise falls back to LocalDiskStorage |
FOCAL_LISTEN_ADDR |
0.0.0.0:8080 |
TCP bind address |
FOCAL_STORAGE_DIR |
/var/lib/focal-proxy |
Root for flat-file storage (used only when DATABASE_URL is unset) |
FOCAL_MAX_BATCH_BYTES |
52428800 (50 MiB) |
Maximum inbound request body size |
FOCAL_REQUIRE_CHECKSUM |
true |
Validate SHA-256 checksum on each batch (false for dev) |
RUST_LOG |
info |
Log filter (e.g. focal_proxy=debug,tower_http=trace) |
| Variable | Default | Description |
|---|---|---|
FOCAL_PROXY_URL |
http://localhost:8080 |
Upstream proxy URL for Next.js rewrites |
PORT |
3000 |
HTTP port (Docker only) |
All endpoints are served by focal-proxy on port 8080.
Ingest a telemetry batch.
Headers:
Content-Type: application/x-protobuf(orapplication/octet-stream)Content-Encoding: gzip(optional — omit for uncompressed proto)
Body: Protobuf-encoded TelemetryBatch
Response 202 Accepted:
{ "status": "accepted", "batch_id": "...", "event_count": 42 }Error codes:
| HTTP | Code | Reason |
|---|---|---|
| 413 | body_too_large |
Body exceeds FOCAL_MAX_BATCH_BYTES |
| 400 | decompression_failed |
Invalid gzip stream |
| 400 | decode_error |
Malformed protobuf |
| 400 | checksum_mismatch |
SHA-256 does not match TelemetryBatch.checksum_sha256 |
{ "status": "ok", "version": "0.1.0" }Returns up to limit (max 200) recent batches, newest first.
[
{
"batch_id": "...",
"received_at": "2026-05-05T04:00:00Z",
"device_id": "dev-a1b2c3d4",
"app_version": "2.1.0",
"os_name": "iOS 17.4",
"event_count": 18,
"flush_reason": "threshold",
"flush_trigger_workflow_id": "wf-monitor-errors",
"sdk_version": "0.3.1",
"compressed_bytes": 14820
}
]Returns all events from a batch.
{
"batch_id": "batch-001",
"events": [
{
"event_id": "evt-001-01",
"sequence_number": 1,
"monotonic_ns": 100000000,
"kind": "lifecycle",
"severity": "info",
"message": "app_foreground",
"extra": { "event_type": "app_foreground" }
}
]
}{
"total_batches": 15,
"total_events": 142,
"total_compressed_bytes": 248040,
"unique_device_count": 5
}Returns the workflow bundle for app_id. Supports conditional GET via If-None-Match header (returns 304 Not Modified on ETag match).
Replace the workflow bundle for app_id. Accepts JSON or protobuf body.
| Page | Route | Description |
|---|---|---|
| Overview | / |
KPI strip (events, batches, devices, wire size), recent flushes table, flush-reason donut chart |
| Event Log | /events |
Pick a batch, browse events with severity filter and full-text search |
| Network | /network |
HTTP requests filtered from any batch; method badge, status code, duration |
| Workflows | /workflows |
Workflow bundle viewer with inline JSON editor; save pushes to proxy |
| Settings | /settings |
Proxy health indicator, storage stats, connection reference |
focal-lens/
├── crates/
│ ├── focal-core/ # Proto codegen, gzip helpers, shared types
│ ├── focal-ffi/ # uniffi FFI layer (iOS Swift, Android Kotlin)
│ └── focal-proxy/
│ ├── migrations/ # SQL migrations (run on startup via sqlx::migrate!)
│ │ └── 0001_initial.sql
│ └── src/
│ ├── config.rs # Environment-variable config
│ ├── error.rs # ProxyError → HTTP response mapping
│ ├── routes.rs # axum handlers
│ ├── storage.rs # StorageBackend trait + LocalDiskStorage + PgStorage
│ ├── telemetry.rs # JSON structured logging
│ └── main.rs # Startup: connect DB, run migrations, bind TCP
├── dashboard/
│ └── src/
│ ├── app/ # Next.js App Router pages
│ ├── components/
│ │ ├── dashboard/ # Overview KPIs + charts
│ │ ├── events/ # Event log browser
│ │ ├── network/ # Network inspector
│ │ ├── workflows/ # Workflow bundle editor
│ │ ├── settings/ # Health + config display
│ │ ├── layout/ # Sidebar + mobile header
│ │ └── ui/ # shadcn/ui primitives
│ └── lib/
│ ├── api.ts # TanStack Query hooks
│ ├── types.ts # Shared TypeScript types
│ └── utils.ts # cn() helper
├── proto/
│ ├── telemetry.proto # TelemetryBatch, TelemetryEvent, ...
│ └── workflow.proto # WorkflowBundle, Workflow, ...
├── scripts/
│ ├── seed.sql # SQL seed for PostgreSQL
│ └── seed.py # Python seed for flat-file storage
├── Dockerfile.proxy # Multi-stage Rust build
├── Dockerfile.dashboard # Multi-stage Next.js build
├── docker-compose.yml # Full stack + seed service
└── Makefile # Dev shortcuts
The proxy starts before Postgres is fully ready. It retries automatically via depends_on: condition: service_healthy. If it persists, check:
docker compose logs postgresRun the seed first:
docker compose run --rm seedThen refresh the dashboard.
# macOS
brew install protobuf
# Ubuntu / Debian
apt-get install -y protobuf-compilerThe proxy uses sqlx::query() (runtime, not compile-time). If you see this error, a query! macro accidentally crept in. All PgStorage queries use sqlx::query() without the !.
Set FOCAL_REQUIRE_CHECKSUM=false in development. The SDK computes the checksum correctly; this flag is only needed when testing with hand-crafted payloads.
# Find and kill the process using the port
lsof -ti:8080 | xargs kill -9
lsof -ti:3000 | xargs kill -9Failed to download `Inter` from Google Fonts
This is harmless in environments without internet access. The dashboard falls back to system fonts.
The first build compiles the full dependency graph (~200 crates). Subsequent builds are cached at the dependency layer. On Apple Silicon, use --platform linux/arm64 if targeting ARM:
docker compose build --build-arg TARGETPLATFORM=linux/arm64docker compose down -v # removes containers + DB volume
docker compose up --build --force-recreate



