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