Logging
Core.Logging.Log is the one way the app writes a log line. It takes a Category and a Severity,
and fans the line out to three places at once:
-
the Unity console, prefixed
[Category], which is what you read in the editor; -
LogHistory, the in-memory ring the on-device debug viewers read, in dev mode only; -
the
LogReportedevent, whichTelemetryServicebatches and ships to OpenTelemetry.
Log is not the only thing that raises LogReported — see UnityLogRelay for
the errors Unity reports on its own.
Everything but Log.Debug is [Conditional("ARC_LOGGING")], so a build without that define drops
the call and its arguments — an interpolated message costs nothing when logging is off. The define
is currently set on every platform.
LogHistory
The debug viewers cannot read the Unity console, and LogReported is a fire-and-forget event with no
memory, so the tail of the session is kept in LogHistory: a fixed array of Capacity entries,
addressed by a sequence number that only ever grows. The buffer holds the last Capacity sequence
numbers and overwrites the rest, so a number is always offered to TryGet rather than indexed
directly — a viewer holding onto an old one finds out that it has rolled off.
Recording only happens in dev mode. LogHistory.Capturing is pushed from SettingsStore.DevMode
alongside the other dev-mode consumers, and with it lowered both capture paths return immediately, so
a production player pays nothing for a buffer nothing can open — the
debug panel and the old overlay are both behind the same dev-mode gate. Lowering it also `Clear()`s
what has already been recorded, since a session that just left dev mode should not leave its tail
sitting in memory.
The gate starts raised, and settings are applied at AfterSceneLoad — later than the
SubsystemRegistration the buffer initialises at. Boot lines are therefore captured either way, and
then either kept or dropped by that first push. Losing them would take the most interesting part of
the log away from the only people who read it.
Three properties of the design are what make it affordable to leave running under load in dev mode:
- Nothing is formatted at capture
-
An entry stores the raw message, the category and a timestamp. Turning that into a row costs one string, in
bindItem, for the rows actually on screen — not one per line for every line the app has ever logged. This is the single biggest difference from the bufferLogUIused to keep. - The size is fixed
-
Capacityentries, overwritten in place. Note thatCircularBufferis not what backs this:CircularBuffer.Enqueuegrows when full rather than evicting, which is the right behaviour for the audio queues it was written for and the wrong one for a log tail that must survive an eight-hour session. - Readers poll, they are not called back
-
A viewer keeps the last sequence number it has seen and drains everything past it on its own schedule, once a frame. Two hundred lines logged in one frame therefore cost one refresh rather than two hundred, and the capture path never touches UI Toolkit — which also means it stays safe to call from wherever a log was written.
Where entries come from
Two sources, merged:
-
Logrecords directly, so the entry carries itsCategory. -
Application.logMessageReceivedcatches everything else — Unity’s own messages, a plugin’s, and uncaught exceptions with their stack trace. These are recorded asCategory.Unity, a real member of the enum rather than a bucket alongside it, so a viewer filters them with the same bitmask shift as everything else andTelemetryServicegets a scope name for them for free.
Log writes to the console as well as recording, and that console write comes straight back round
through Application.logMessageReceived. LogHistory.SuppressCapture is raised for the length of
that write so the echo is dropped — otherwise every app log would appear twice, once under the
category it was written with and once as Unity. The flag is set and cleared in the same try/finally in Log.Report,
so it can never be left standing and swallow an unrelated message.
Stack traces are kept only for errors. Unity attaches one to every message it reports, and holding a thousand of them would cost more than the rest of the buffer put together.
UnityLogRelay
LogHistory answers the debug viewers, and it only records in dev mode. Telemetry needs the opposite:
a production player is exactly where an unnoticed NullReferenceException matters. UnityLogRelay is
therefore its own Application.logMessageReceived subscriber rather than a branch inside LogHistory,
with no dev-mode gate on it, and it raises LogReported under Category.Unity so the existing
TelemetryService batching, context attributes and upload apply unchanged. Core cannot reference the
telemetry assembly, so the event bus is the only way across anyway.
It narrows what it forwards on three counts:
- Errors only
-
LogType.Error,ExceptionandAssert. Warnings and info from the engine are noise at collector scale, andTelemetryServicereports its own upload failure withDebug.LogWarningprecisely so that a failing exporter cannot feed itself. - Not `Log’s own echo
-
The same
LogHistory.SuppressCaptureflag the buffer uses. Without it everyLog.Errorwould be uploaded twice — once under the category it was written with, once asUnity. - Once per distinct error
-
An exception thrown from
Updaterepeats every frame, and a few seconds of that would fill the 1024-record batch with copies. The key is the message and its stack trace, since a message as generic as aNullReferenceExceptionsays nothing on its own about which one it is. The set of keys is capped atMaxDistinctErrors; a run past that is broken in a way the next line will not explain, and the cap is also what stops a message with a number in it from growing the set without bound.
Viewers
Both index into LogHistory; neither keeps entries of its own, so the buffer’s size is the whole of
what logging costs at rest.
DebugLogViewer-
The list on the debug panel — a
ListViewover the entries that pass the current filter, with a search field, four severity chips and a categoryMultiOptionPicker. Both filters are bitmasks, so testing an incoming entry is a couple of shifts; only a filter or the search text changing re-scans the buffer. It holds sequence numbers, not entries, so a message exists once in the player however many viewers are showing it — and a row whose line has since been overwritten draws as a placeholder rather than disappearing. LogUI-
The section in the old debug overlay. Newest-first, three severity toggles, capped at
maxEntriesrendered rows — which is what that field always meant and never did.