A minimal bug capture setup for Unity
Hooking logs into a ring buffer, snapshotting game state, and writing it to disk — the minimum code to attach to a Unity project, plus the threading pitfall.
- qa
- unity
- bug-report
Unity bug reporting, part 3 of 3 — ① Why reproduction fails · ② Always record, keep what you need · ③ A minimal Unity setup
The first two posts covered what a ticket needs to carry (part 1) and which approach to take (part 2). This one is the code: everything except video — logs and game state — attached to a Unity project.
You don't need all of it. These two alone move the needle on how often a bug can be reproduced.
1. Collect logs into a ring buffer
To catch exceptions reliably, hook the log callback. Use logMessageReceivedThreaded if you want exceptions thrown off the main thread as well.
That choice comes with a cost, though. This callback fires on arbitrary threads. Buffer access needs a lock, and the Time APIs are main-thread only — calling them from a worker thread throws UnityException. It breaks precisely in the case you added Threaded for, so it tends to look fine in development and surface after you ship.
readonly object _lock = new object();
double _lastMainThreadTime;
void OnEnable()
{
// Includes worker-thread exceptions (use logMessageReceived for main thread only)
Application.logMessageReceivedThreaded += OnLog;
}
void OnDisable()
{
Application.logMessageReceivedThreaded -= OnLog;
}
void OnLog(string condition, string stackTrace, LogType type)
{
// Time.* is main-thread only — fall back to the last known value off-thread
double time;
try
{
time = Time.realtimeSinceStartupAsDouble;
_lastMainThreadTime = time;
}
catch
{
time = _lastMainThreadTime;
}
// Don't filter by type. The ordinary log right before an exception is often what
// points at the cause, and a fixed-size ring buffer costs the same either way.
lock (_lock)
{
_ringBuffer.Add(new LogEntry {
Time = time,
Type = type,
Message = condition,
Stack = stackTrace,
});
}
}
_ringBuffer can be as simple as a fixed-length array with a wrapping write index — keep the last N entries and overwrite the oldest.
2. Snapshot game state and environment
This one only needs to run once, at capture time. It's what fills in the "in the inventory, I think" gap.
// Unity 2021.3+ (uses target-typed new)
using System;
using System.Collections.Generic;
using UnityEngine.SceneManagement;
static Dictionary<string, string> Snapshot()
{
// Use unscaledDeltaTime so timeScale doesn't skew it, and guard the divide.
// Capturing while the game is paused is common.
float dt = Time.unscaledDeltaTime;
float fps = dt > 0f ? 1f / dt : 0f;
return new()
{
["scene"] = SceneManager.GetActiveScene().name,
["playtime"] = Time.realtimeSinceStartupAsDouble.ToString("F1"),
["frame"] = Time.frameCount.ToString(),
["fps"] = fps.ToString("F0"),
// Profiler APIs return 0 outside development builds
["memoryMB"] = (GC.GetTotalMemory(false) / 1048576).ToString(),
["resolution"] = $"{Screen.width}x{Screen.height}",
["graphics"] = SystemInfo.graphicsDeviceType.ToString(),
["quality"] = QualitySettings.names[QualitySettings.GetQualityLevel()],
["build"] = Application.version,
};
}
Layer project-specific fields on top: inventory count, quest progress, which server you're on, which live-ops events are active. Those map directly onto the lost conditions from part 1.
The point is that both use the same clock (Time.realtimeSinceStartupAsDouble). Lining any of this up with video frames later requires a shared time axis.
3. Write it out
Without this step you collect everything and keep nothing.
using System.IO;
using System.Linq;
void Update()
{
if (!Input.GetKeyDown(KeyCode.F9)) return;
LogEntry[] logs;
lock (_lock) logs = _ringBuffer.ToArray(); // copy out, keep the lock short
var dir = Path.Combine(Application.persistentDataPath, "bug-reports");
Directory.CreateDirectory(dir);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
File.WriteAllText(Path.Combine(dir, $"{stamp}-state.json"), ToJson(Snapshot()));
File.WriteAllLines(Path.Combine(dir, $"{stamp}-logs.txt"), logs.Select(l => l.ToString()));
}
One hotkey, two files. From here the extensions are fairly obvious: add a video rolling buffer, push the files into your ticket system, and view them on a shared timeline in something the whole team can open.
We packaged this flow as a Unity plugin and ship it as Rekon. Whether you wire it up yourself or use a tool, the point is the same: the state at the moment a bug happens can only be captured then.