Unity's logging system, in depth ③ — what IL2CPP and release builds change

Why log collection that worked perfectly in the editor behaves differently in a build. Stack trace settings, IL2CPP symbols and stripping, dev-build-only APIs, and the cost of Debug.Log itself.

  • unity
  • logging
  • il2cpp

Unity logging, part 3 of 3 — ① Callback pitfalls · ② A GC-free ring buffer · ③ What changes in builds

The code from part 1 and part 2 runs perfectly in the editor. The problem is that the place log collection is actually needed isn't the editor — it's the build on a QA machine. Builds, IL2CPP release in particular, change both the shape and the cost of logs. When what you verified in the editor differs from what QA receives, the collector is only half a collector at the exact moment it matters.

1. Stack traces are a function of settings

Whether stack traces are captured per log type is both a build setting (Player Settings → Stack Trace) and a runtime API.

// Per type — full stack for exceptions, none for ordinary logs (saves cost)
Application.SetStackTraceLogType(LogType.Exception, StackTraceLogType.ScriptOnly);
Application.SetStackTraceLogType(LogType.Log,       StackTraceLogType.None);

Not knowing this setting bites you in two directions. If it's None, the callback's stackTrace argument arrives as an empty string and you misdiagnose it as "the collector is broken." If ordinary logs are set to ScriptOnly, stack capture cost accumulates visibly in a log-heavy project. Pin this setting explicitly before shipping a collector.

2. IL2CPP — you get the stack, but no line numbers

IL2CPP stack traces differ from Mono's. In release builds it's typical to get function names but no file or line numbers, and inlined functions can lose their frame entirely.

  • If you need line numbers: a development build with script debugging, or post-hoc resolution using the build's symbol files
  • Symbol files (.sym / line mappings) are per-build. Resolving crashes and logs after the fact requires the discipline of keeping build artifacts and symbols at the same version — one more reason the build number belongs in the report.

3. Stripping — reflection quietly disappears

IL2CPP's managed code stripping removes code that isn't "referenced." The catch is that static analysis can't see code referenced only through reflection. If your log collector scrapes game state via reflection (field dumps, serialization libraries), what worked in the editor comes back silently empty in builds only.

There are three responses — preserve declarations in link.xml, the [Preserve] attribute, or an explicit collection structure with no reflection at all. We recommend the last. Stripping level (Minimal/Low/Medium/High) varies by project settings, so depending on preserve declarations means one settings change can regress you.

4. Development-build-only APIs — a world where 0 is correct behavior

Profiler APIs like Profiler.GetTotalAllocatedMemoryLong return 0 in release builds. Not an exception — zero. If the collector puts that value straight into a report, you manufacture a "0MB memory" data point, and whoever believes it draws the wrong conclusion.

The principle is the same as in the measurement postdistinguish an unsupported 0 from a measured 0. Branching on Debug.isDebugBuild to omit the field or mark it "n/a" in release is better than shipping the zero.

5. The cost of Debug.Log itself — the final twist

The series has treated logs as data handed to you for free, but Debug.Log calls aren't exactly cheap. String assembly, stack capture (depending on settings), console and file output — in a hot path with frequent logging, the logging is the performance problem.

The standard way to remove development logs from release builds is conditional compilation.

using System.Diagnostics;   // careful not to confuse with UnityEngine.Debug

public static class Log
{
    [Conditional("DEVELOPMENT_BUILD"), Conditional("UNITY_EDITOR")]
    public static void Dev(string message) => UnityEngine.Debug.Log(message);
    // In release builds the call itself is compiled out — even argument evaluation disappears
}

But strip all of them and you're back to the problem from part 1 of the earlier series — nothing remains, so nothing reproduces. The rule is "remove development logs from hot paths, keep event logs (exceptions, warnings, state transitions)." A ring buffer has fixed capacity, so keeping event logs doesn't grow memory.

Summary — final verification happens in a build

Reduced to one line, the three posts say this: a log collector's unit tests run in the editor, but its final verification must happen in a target build. Stack trace settings, IL2CPP symbols, stripping, dev-only APIs — none of those four differences are visible from the editor.

It's also the list our Rekon SDK compiled by stepping on each trap in turn. If you're building this yourself, we hope this series is a map that shortens the trial and error.