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
| Module | Lines (incl. tests) | Owns | Never does |
|---|---|---|---|
main | 98 | Process lifecycle: arg parsing, poll loop, signal handling, wall-clock timestamps | Parse JSON, build URLs |
config | 263 | Loading, env overrides, validation of Config | Touch the network |
model | 40 | LogEntry / GameLog serde types | Any logic |
tracker | 259 | The session state machine and the in-memory log | I/O of any kind |
enrich | 190 | Turning friend summaries + A2S replies into optional fields | Fail — it degrades instead |
storage | 210 | Reading and atomically writing the log file | Know about Steam |
a2s | 331 | The A2S_INFO UDP protocol | Know about sessions |
steam::api | 334 | URL builders, raw↔domain types, pure parsers | Perform requests |
steam::client | 125 | Blocking HTTP, chunking, error context | Interpret 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
| State | Lifetime | Owner |
|---|---|---|
Config | whole process, immutable after load | main::run |
GameLog (all entries) | whole process, mutated each tick | Tracker |
active_index | whole process | Tracker |
| HTTP connection pool | whole process | SteamClient (reqwest) |
| UDP socket | one per A2S query | A2sClient::query |
| The log file | forever | disk |
A2sClient holds only a timeout; it binds a fresh ephemeral UDP socket per
query and drops it afterwards.
Keep reading
- The poll loop — what one tick does, in order, with costs.
- Session lifecycle — the state machine and resume semantics.
- Enrichment pipeline — the three enrichment sources and their matching rules.
- Resilience — every failure mode and what it degrades to.
- Code reference — item-by-item API documentation.