LoggerPro — Runtime/Storage¶
InMemoryLoggerStorage.cs — the session's log store. One class, implementing ILoggerStorage, IClearable and ISessionRestorable.
It holds entries in a list for display, a fingerprint map for deduplication, and a per-category counter for the caps. Three collections that have to agree with each other, and almost everything below is about a way they once did not.
The standard this is held to is not "fast" or "small". It is that a diagnostics tool must not invent evidence or lose it. A console that drops the error still happening, or shows two unrelated messages folded into one row with a count of 2, is worse than no console — because it is believed.
It is called from any thread¶
The capture path queues Unity's messages and drains them on the main thread. The public API does not: DevLog and LoggerPro.Log run on whatever thread called them, and the headline feature is logging from game code, which has loading threads, workers and jobs.
A game logging from a worker was mutating these three collections unsynchronised while the console repainted from them. That produced torn dictionaries, entries that vanished, caps counted wrong for the rest of the session, and InvalidOperationException raised inside the user's own code.
So a single lock guards all three, every public method takes it, and the nested calls between them rely on Monitor being reentrant. It is uncontended in the ordinary single-threaded case.
GetLogs() returns a copy of the list, not the list. Callers iterate it — the console once per repaint, the exporters once per export, session persistence on every reload — and a worker adding an entry partway through that iteration threw in the caller. The entries themselves are shared, so pinging, counting and exporting all still see live data; only the order of the list is frozen, for as long as it takes to read it.
Deduplication¶
An entry is a repeat when its category, severity and details all match. The header is not part of the key, because it carries a timestamp.
A repeat does not append. MarkSeenAgain bumps the counter and the last-seen time together, and the most recent context object replaces the old one so "ping" targets the latest occurrence rather than the first.
The key is length-prefixed, not joined¶
category.Length : category severity.Length : severity details.Length : details
A plain "|" join collides whenever a category or a severity token contains the separator. A log in category Net|ERROR reading x produced the same key as an error in category Net reading INFO|x — two entries that shared nothing, folded into one row with a count of 2. Length prefixes cannot collide that way.
Both halves of a repeat have to move¶
Only the counter used to move. The last-seen time stayed at first sighting, which meant an error recurring every frame read as a single occurrence from minutes ago — and then eviction, which walked the list in order, threw it out ahead of the one-off messages sitting above it.
The caps¶
| Per category | LoggerPro.MaxLogsPerCategory, from EditorPrefs, clamped to at least 1 |
| Globally | MaxTotalLogs = 500, a hard constant |
The per-category cap is resolved through a Func<int> on every trim rather than read once in the constructor, so the store does not bake in an EditorPrefs value at construction time and tests can pass an explicit provider instead of depending on whoever's preferences are on the machine.
The clamp to 1 is not defensive padding: without it a preference value of zero evicts the entry that was just added, so logging appears to do nothing at all.
Per-category eviction drops the entry last seen longest ago, not the one lowest in the list. List position is first-sighting order, so a deduplicated entry still occurring stays where it started — and the cap is meant to drop what is stale. The one thing that is certainly not stale is the message arriving right now.
The global cap is the blunt one, and deliberately so: it removes from the front of the list regardless of last-seen. It exists to stop memory running away in a session left open for days, and at 500 entries across every category it is a backstop rather than a filter.
Surviving a domain reload¶
Restore(entries) takes back what was captured before a reload. It is not re-adding: AddLog would stamp a fresh timestamp and count each entry as a new occurrence, and these already happened.
What it does, in order: sets aside anything already in the store, clears, reinstates the restored entries, then reinstates the newer ones on top. Anything already present was logged after the reload, so it has to end up after the restored entries, not interleaved with them.
The bookkeeping is rebuilt from the entries rather than carried alongside them. The fingerprint map is what makes a repeat of a restored log increment its counter instead of appearing twice, so it has to agree exactly with what AddLog would compute for the same entry — and the only way to guarantee that is to compute it the same way.
A payload that has been hand-edited, or written by a different build, can contain two entries that fingerprint the same. Those are folded, counts added, latest sighting winning, rather than overwriting the map — which would leave the second copy permanently un-deduplicatable.
The rest¶
RemoveCategory(category) — drops every entry in one category and its bookkeeping, returning how many went. Used when a new compilation starts, to clear captured compiler messages: they describe code that no longer exists, and showing them as current outlasts the recompile that proved them fixed.
Clear() — all three collections, together. The per-category counters used to survive this, which retained the evicted entries in the accounting and left the caps mis-counted for the rest of the session.
Replacing it¶
ILoggerStorage is an injectable seam, so a file-backed, ring-buffered or remote store can be passed to LoggerPro.Initialize(...) instead of this one. Two things to carry over:
- Do not persist the
contextobject into an exported report. It is a liveUnityEngine.Objectreference, retained so the console can ping it. In the project of whoever receives the report it means nothing. - Implement
ISessionRestorableonly if you need it. A store backed by a file or a database already survives a domain reload and has nothing to do there.
See Runtime/Interfaces/README.md for why that second point is a separate interface rather than a method on ILoggerStorage.