Architecture

The shape of the system, and the handful of decisions that determine everything else. The full contract lives in docs/ARCHITECTURE.md in the repository.

browser                        your backend
┌──────────────┐               ┌──────────────┐
│ syncline-sdk │               │ your app     │
│ rrweb capture│  traceparent  │ + OTel SDK   │
│ fetch patch  ├──────────────►│              │
└──────┬───────┘               └──────┬───────┘
       │ chunks + links               │ OTLP/HTTP
       ▼                              ▼
   ┌────────────────────────────────────────┐
   │ apps/api      authenticate, store, 202 │
   └───────────────┬────────────────────────┘
                   │ BullMQ (pointers, not bodies)
   ┌───────────────▼────────────────────────┐
   │ apps/worker   parse → normalize → store│
   └──────┬──────────────────┬──────────────┘
          ▼                  ▼
     Postgres           object store
          ▲
   ┌──────┴──────────────────┐
   │ apps/web      the viewer│
   └─────────────────────────┘

The API parses nothing

Ingest is the one path that must not fall over under load, and the one path whose input is attacker-controlled. So the API authenticates the key, bounds the size, streams the body to object storage, enqueues a job carrying the storage key, and returns 202. Decompression, schema validation and indexing happen in the worker, where a slow or hostile payload costs a queue slot instead of an HTTP connection.

This also keeps queue jobs small. Payloads are pointers, never megabytes — Redis is a queue, not a blob store.

The join is by identifier

A browser clock and a server clock disagree, sometimes by hours. If the two halves were correlated by timestamp, every skewed clock would be a correctness bug. Instead the browser mints the trace id, the backend continues it, and the join is an equality check on 128 bits. Skew only affects where a span is drawn, and the viewer shows the measurement uncertainty rather than pretending it is not there.

Sampling is inverted

Normally the backend decides what to keep and the frontend finds out later, which produces the worst possible artifact: the replay of a slow request whose spans were discarded. Here the browser decides. A recorded session forces sampled=1, and parent-based sampling honours it.

The recording carries its own index

Trace ids are written into the rrweb stream as custom events rather than kept in a side table. A session file is therefore self-describing: export it, hand it to someone else, and it still resolves to its spans. Two events per request, not one — rrweb’s log is append-only, so a duration cannot be stamped onto an event already emitted.

Storage split

Postgres holds the index; the object store holds the film. A five-minute recording is tens of megabytes of DOM mutations and has no business in a relational database. Spans are the one table with unbounded write volume, so everything reaches them through a SpanStore interface — the eventual move to ClickHouse should be one new class, not a rewrite.

Everything downstream is idempotent

A queue promises at-least-once delivery and nothing more. Chunks upsert on (sessionId, seq), spans on (traceId, spanId), and a body that will never validate raises an unrecoverable error so it fails once rather than three times with backoff.

Packages

PackageRole
@syncline/protocolEvery contract crossing a process boundary. A leaf with no workspace deps
@syncline/modelsPrisma schema, client, and the SpanStore port
@syncline/otlpThe only code that knows OpenTelemetry’s wire format
@syncline/storageOne object-store client, so API and worker cannot drift
syncline-browserThe recorder that ships to your site