Skip to main content

Session lifecycle

src/tracker.rs· 259 lines

The Tracker turns a stream of "what is running right now?" observations into a list of sessions with start and end times. It holds two pieces of state and nothing else:

pub struct Tracker {
log: GameLog, // every entry, including historical ones
active_index: Option<usize>, // index of the open entry, if any
}

States and transitions​

Each poll calls exactly one of update (a game is running) or end_active (nothing is running), and gets back a SessionEvent:

EventMeaningLog mutation
Started(i)A new session began at index iNew entry pushed with start = now, end absent
Running(i)The same game is still runningNone
EndedThe open session was closedend = now on the active entry
IdleNothing was running before or afterNone

:::note The binary ignores the return value main discards the SessionEvent (let _event = …); the enum exists so that the state machine is observable and unit-testable, and so library users can react to transitions. See Library usage. :::

Switching games​

When a poll reports a different game, both operations happen in the same tick with the same timestamp:

t.update(&game("Dota 2"), "T0"); // Started(0)
t.update(&game("CS2"), "T1"); // Started(1): entry 0 gains end = "T1"
{"games": [
{"name": "Dota 2", "start": "T0", "end": "T1"},
{"name": "CS2", "start": "T1"}
]}

Sessions therefore never overlap and never leave gaps between a switch — the old end and the new start are the same instant.

:::warning Identity is the game name, not the AppID update compares self.log.games[index].name == game.name. Two different games that report the same title would be treated as one continuous session, and a game that changes its reported title mid-session (rare, but possible with gameextrainfo) would be recorded as a switch. The AppID is available on CurrentGame but is not used for identity. :::

Resuming after a restart​

Tracker::new inspects the log it is given:

let active_index = log.games.last().and_then(|last| match last.end {
Some(_) => None, // last session finished → idle
None => log.games.len().checked_sub(1), // still open → resume it
});

That single rule gives SteamLogger crash tolerance for free:

Consequences to be aware of:

  • Only the last entry is considered. Historical entries missing end (hand-edited files) are never repaired.
  • The resumed name is not checked against what is running now. If you quit SteamLogger during TF2 and restart it while playing CS2, the first poll sees a different name and performs a switch: the TF2 entry is closed with the current timestamp (inflating its duration by the downtime) and CS2 starts fresh.
  • Downtime is invisible. A session that spans a 6-hour outage looks like one 6-hour session. If precision matters, run under a supervisor that restarts quickly (Running continuously).

Attaching enrichment​

apply_meta(index, map, server, friends_playing, friends_in_lobby) overwrites the four optional fields of one entry, with one normalisation rule: empty slices become None, so an empty list never shows up in the JSON.

entry.map = map.map(str::to_string);
entry.server = server.map(str::to_string);
entry.friends_playing = (!friends_playing.is_empty()).then(|| friends_playing.to_vec());
entry.friends_in_lobby = (!friends_in_lobby.is_empty()).then(|| friends_in_lobby.to_vec());

Because every tick calls it with freshly computed values, the fields always describe the latest observation rather than a union over the session. A tick where enrichment failed clears them; the next successful tick restores them.

apply_meta is the only tracker method that can fail, and only for an out-of-bounds index (entry index N out of bounds). main guards against that by passing tracker.active_index().

Worked example: a full evening​

TickSteam reportsCallEventLog after the tick
1nothingend_active("T1")Idle[]
2TF2update(TF2, "T2")Started(0)[TF2 T2–]
3TF2update(TF2, "T3")Running(0)[TF2 T2–]
4CS2update(CS2, "T4")Started(1)[TF2 T2–T4, CS2 T4–]
5nothingend_active("T5")Ended[TF2 T2–T4, CS2 T4–T5]
6nothingend_active("T6")Idleunchanged

Tick 6 shows the idempotence of end_active: closing an already-closed log is a no-op, and close_at additionally refuses to overwrite an existing end.

Tested behaviour​

The state machine has ten unit tests plus two integration tests, one per transition and edge case: empty_tracker_starts_idle, start, running, switch, end, resume, closed_last_entry_not_resumed, apply_meta, apply_meta_out_of_bounds, into_log_returns_underlying, plus tracker_end_to_end and tracker_resume_closes_open_session. See the test inventory.