Skip to content

Repository files navigation

Focal Lens

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.


Table of Contents


Architecture

┌─────────────────┐      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 batches
  • GET/PUT /v1/workflows[/:app_id] — workflow bundle CRUD
  • GET /v1/batches — paginated batch list
  • GET /v1/batches/:id/events — events from a single batch
  • GET /v1/stats — aggregated metrics
  • GET /v1/health — liveness probe

Features

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

Tech Stack

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)

Prerequisites

Docker (recommended)

  • Docker Engine ≥ 24
  • Docker Compose v2 (docker compose not docker-compose)

Local development

  • Rust 1.82+ (rustup update stable)
  • Node.js 20+ (node --version)
  • protoc ≥ 3.21 (brew install protobuf on macOS)
  • PostgreSQL 16 (optional — proxy falls back to local disk without it)

Quick Start (Docker)

# 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:3000

Services:

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)

Local Development

1. Start PostgreSQL

# 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

2. Start focal-proxy

# 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-proxy

Migrations run automatically on startup when DATABASE_URL is set.

3. Seed data

# 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 50

4. Start the dashboard

cd dashboard
npm install
npm run dev

Open http://localhost:3000 — the dashboard proxies API calls to http://localhost:8080 via Next.js rewrites.


Seed Data

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 attributes
    • network — HTTP requests with method, URL, status code, duration, and byte counts
    • interaction — user taps/swipes with element IDs and screen names
    • lifecycle — app_start, app_foreground, app_background, app_crash
  • 1 workflow bundle placeholder in the workflow_bundles table

scripts/seed.py generates randomised flat-file seed data (useful when running without PostgreSQL):

python3 scripts/seed.py 100    # seed 100 random batches

Configuration

All configuration is via environment variables (no config file needed).

focal-proxy

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)

Dashboard

Variable Default Description
FOCAL_PROXY_URL http://localhost:8080 Upstream proxy URL for Next.js rewrites
PORT 3000 HTTP port (Docker only)

API Reference

All endpoints are served by focal-proxy on port 8080.

POST /v1/flush

Ingest a telemetry batch.

Headers:

  • Content-Type: application/x-protobuf (or application/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

GET /v1/health

{ "status": "ok", "version": "0.1.0" }

GET /v1/batches?limit=50

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
  }
]

GET /v1/batches/:batch_id/events

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" }
    }
  ]
}

GET /v1/stats

{
  "total_batches": 15,
  "total_events": 142,
  "total_compressed_bytes": 248040,
  "unique_device_count": 5
}

GET /v1/workflows/:app_id

Returns the workflow bundle for app_id. Supports conditional GET via If-None-Match header (returns 304 Not Modified on ETag match).

PUT /v1/workflows/:app_id

Replace the workflow bundle for app_id. Accepts JSON or protobuf body.


Screenshots

Overview

Overview

Event Log

Event Log

Network Inspector

Network Inspector

Workflows

Workflows

Settings

Settings


Dashboard Pages

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

Project Structure

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

Troubleshooting

docker compose up fails with "failed to connect to PostgreSQL"

The proxy starts before Postgres is fully ready. It retries automatically via depends_on: condition: service_healthy. If it persists, check:

docker compose logs postgres

Dashboard shows "No flush data yet"

Run the seed first:

docker compose run --rm seed

Then refresh the dashboard.

cargo build fails with "protoc not found"

# macOS
brew install protobuf

# Ubuntu / Debian
apt-get install -y protobuf-compiler

cargo build fails with "DATABASE_URL required"

The 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 !.

FOCAL_REQUIRE_CHECKSUM errors when testing

Set FOCAL_REQUIRE_CHECKSUM=false in development. The SDK computes the checksum correctly; this flag is only needed when testing with hand-crafted payloads.

Port 8080 or 3000 already in use

# Find and kill the process using the port
lsof -ti:8080 | xargs kill -9
lsof -ti:3000 | xargs kill -9

Dashboard font warnings in logs

Failed to download `Inter` from Google Fonts

This is harmless in environments without internet access. The dashboard falls back to system fonts.

Docker build is slow (Rust compilation)

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/arm64

Resetting everything

docker compose down -v   # removes containers + DB volume
docker compose up --build --force-recreate

About

An edge observability platform for mobile applications

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages