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
| Package | Role |
|---|---|
@syncline/protocol | Every contract crossing a process boundary. A leaf with no workspace deps |
@syncline/models | Prisma schema, client, and the SpanStore port |
@syncline/otlp | The only code that knows OpenTelemetry’s wire format |
@syncline/storage | One object-store client, so API and worker cannot drift |
syncline-browser | The recorder that ships to your site |