Skip to content

Professional

BigBrotr

An OpenSats-funded observatory for the Nostr network: eight independent Python services that discover relays across four transport networks, archive what they serve, and expose it through a read-only API and a paid data-vending machine.

Apr 2025OpenSats grantNostrData engineeringDistributed systems

BigBrotr is a storage-first observatory for Nostr: it discovers relays, checks whether they are what they claim to be, archives the events they serve, and exposes the result to anyone who wants to ask a question about the network rather than about one relay.

Funding
OpenSats grant
Shape
Eight independent async services, one PostgreSQL 18 database
Networks
Clearnet · Tor · I2P · Lokinet
Repository
BigBrotr/bigbrotr
BigBrotr/bigbrotrModular Nostr network observatory: relay discovery, health monitoring, event archiving, analytics, and data access.Python

The problem

There is no register of Nostr relays. There is no authority that knows how many exist, which are reachable, which honour the protocol, or what any of them are actually serving, and by design there never will be. A client sees the handful of relays it was configured with; a measurement of the network has to construct its own view from nothing and keep that view correct while the network moves underneath it.

Three things make that harder than a crawl. Relays appear and disappear continuously. A meaningful fraction are only reachable over Tor, I2P or Lokinet, each with its own latency and concurrency profile. And “did I get everything this relay has?” is not a question a relay will answer: you have to be able to prove completeness yourself.

What I built

Eight services – a seeder, a finder, a validator, a monitor, a synchronizer, a refresher, the read API, and a data-vending machine – that run on their own schedules and never call each other. All coordination goes through a single service_state table keyed by service, state type and state key, holding JSONB. There is no message bus, no queue, and no RPC between services, so any one of them can be stopped, restarted or replaced without the others noticing.

250M+

events archived

5,000+

relays monitored

2,000+/s

sustained ingest

These are deployment snapshots from the instance I operate, not published benchmarks. They vary with the relay set and the collection window.

Discovery is a loop rather than a list: the finder reads relay directories and scans already archived events for relay URLs in their tags, so the archive feeds the discovery that fills the archive. The validator promotes a candidate only on protocol-level evidence – an EOSE, a NIP-42 auth challenge, or an auth-required close – and does so in one transaction that inserts the relay and deletes the candidate together. The monitor runs seven distinct health checks across NIP-11 and NIP-66, with per-network concurrency limits: fifty in flight on clearnet, ten on Tor, five each on I2P and Lokinet.

BigBrotr also publishes back. It is itself a NIP-66 monitor, emitting relay-discovery events to the network it measures, and the data-vending machine answers NIP-90 job requests with per-table pricing and a payment-required feedback path.

The storage is where most of the design lives. Events are keyed by their own SHA-256 id, so re-ingesting one is a no-op; event and event_relay are both hash-partitioned sixteen ways on that same key, which the changelog notes is deliberate: same key, so joins stay partition-wise. Tag values are flattened at insert time into a key:value form that keeps only single-character tag keys, so a GIN index can tell an e tag from a p tag with the same value. Thirteen materialized views carry the analytics, refreshed concurrently by a service that owns them under its own database role.

The same eight services and the same schema also build as LilBrotr, a metadata-only variant from the same parametric Dockerfile, with content, tags and signatures left unpopulated for roughly 60% of the disk.

Hard parts

Proving completeness against a relay that will not tell you. The synchronizer walks a relay’s history with binary-split windowing: request a window, then re-fetch its boundary to verify nothing was missed, and on failure split at the midpoint and push both halves back onto the stack. Single-second windows are indivisible and yield as they are. The cost is re-reading boundaries you have already read; what it buys is that a gap is detected rather than assumed absent.

Coordination through durable state instead of direct calls. Every checkpoint, cursor and failure count lives in one table rather than in memory or in a queue. That costs write volume and an extra round trip on every cycle, and it buys independent restarts: a service that dies mid-window resumes from the last committed cursor rather than from the beginning.

A memory leak that was not in my code. Running this in production surfaced the kind of problem no test finds. The Event model delegated attribute access to the underlying nostr_sdk object, which is Rust behind a Python binding, so the Python garbage collector could not see what it was holding, and the synchronizer reached 54 GB RSS in eleven hours. Replacing that convenient delegation with explicit fields fixed it, trading ergonomics for memory the runtime can account for. A second, separate leak turned out to be glibc’s per-thread arenas never returning pages from the binding’s Tokio threads; the fix was jemalloc, which I then removed after clean local benchmarks and had to restore once it became clear the local platform simply does not have that allocator behaviour.