Skip to main content

Architecture overview

SteamLogger is deliberately small: about 800 lines of library code (plus ~960 lines of tests living beside it) and a 98-line binary — no async runtime, no threads other than the one the ctrlc crate installs, and no state outside one in-memory GameLog and one file on disk.

The shape of the program​

Every arrow is a compile-time dependency. The graph is acyclic and shallow: model depends on nothing, api only on model, and the binary is the only place where HTTP, UDP, the clock and the filesystem meet.

Module responsibilities​

ModuleLines (incl. tests)OwnsNever does
main98Process lifecycle: arg parsing, poll loop, signal handling, wall-clock timestampsParse JSON, build URLs
config263Loading, env overrides, validation of ConfigTouch the network
model40LogEntry / GameLog serde typesAny logic
tracker259The session state machine and the in-memory logI/O of any kind
enrich190Turning friend summaries + A2S replies into optional fieldsFail — it degrades instead
storage210Reading and atomically writing the log fileKnow about Steam
a2s331The A2S_INFO UDP protocolKnow about sessions
steam::api334URL builders, raw↔domain types, pure parsersPerform requests
steam::client125Blocking HTTP, chunking, error contextInterpret payloads

Design rules the code follows​

1. Pure core, thin I/O shell​

Parsing (steam::api), state transitions (tracker) and matching (enrich) are plain functions over plain data. They are unit-tested without a network, a clock or a filesystem. Only steam::client, a2s, storage and main perform I/O, and each of them is a thin wrapper.

This is why now() lives in main and timestamps are passed into Tracker::update(&game, now) as a string: the state machine has no clock to mock.

2. Enrichment is optional, everywhere​

LogEntry models every enrichment field as Option, enrich::analyze returns Enrichment::default() instead of an error, and Tracker::apply_meta stores empty vectors as None. A degraded tick produces a smaller entry, never a failed run. See Resilience.

3. One writer, whole-file writes​

There is no append path and no partial update. The in-memory GameLog is the source of truth during a run; storage::write_log serialises all of it, every tick, through a temp file and a rename. Simple, atomic, and bounded by how big your history gets.

4. Blocking, single-threaded, zero scheduling​

reqwest's blocking client and a std::sync::mpsc receiver with recv_timeout replace an async runtime: the loop sleeps on the channel, and Ctrl+C wakes it instantly. The only extra thread is the signal handler installed by ctrlc.

5. The library is usable on its own​

src/lib.rs re-exports all seven modules, so the crate can be consumed programmatically — see Library usage. The binary is just the default wiring of those parts.

Data flow in one pass​

Where state lives​

StateLifetimeOwner
Configwhole process, immutable after loadmain::run
GameLog (all entries)whole process, mutated each tickTracker
active_indexwhole processTracker
HTTP connection poolwhole processSteamClient (reqwest)
UDP socketone per A2S queryA2sClient::query
The log fileforeverdisk

A2sClient holds only a timeout; it binds a fresh ephemeral UDP socket per query and drops it afterwards.

Keep reading​