Platform guide

Reporting exceptions from a Unity game

When code in a Unity game throws, the game usually keeps running. That frame's work is skipped, a line goes to a log file on the player's device, and nobody on the team ever sees it. Here's how to catch those exceptions, what to send with them, and what C# can't catch at all.

An exception doesn't crash a Unity game

Unity calls your code from its player loop: Update, coroutines, UI event handlers, physics callbacks. Each call is wrapped in a try/catch. When your code throws, Unity catches the exception, logs it as LogType.Exception and moves on to the next call. The rest of that method never runs, so a menu doesn't open, an item isn't granted, or a level loads without its enemies, but the game doesn't quit.

That's kind to players and hard on developers. In a build, the log goes to a file on the player's device (Player.log on desktop, the system log on a phone). Players don't send log files. They leave a review, or stop playing. To hear about exceptions, the game has to send them somewhere itself.

Catching what Unity logs

Application.logMessageReceivedThreaded is called for every message Unity logs, including the exceptions its player loop caught, with the message, the stack trace and the log type. Use it rather than logMessageReceived, which only fires on the main thread, because game code logs from worker threads too.

Since it runs on whichever thread logged, the handler has to be thread safe, can't use Unity APIs that only work on the main thread, and mustn't call Debug.Log, which would call the handler again. So the handler only queues a report, and a MonoBehaviour sends it from the main thread, where UnityWebRequest has to run. UnityWebRequest is also the only HTTP client that works in a WebGL build, which has no sockets and no background threads.

An exception thrown on a thread you started yourself, with new Thread, never reaches the log. It goes to AppDomain.UnhandledException, so hook that too. A minimal reporter:

C#, Assets/Scripts/ErrorReporter.cs

using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

// Hears every exception Unity logs, on any thread, and sends it from the main thread.
public class ErrorReporter : MonoBehaviour
{
    const string Endpoint = "https://errors.example.com/report"; // your own collector
    const int MaxQueued = 50;

    static readonly ConcurrentQueue<string> Pending = new ConcurrentQueue<string>();
    static string build, platform, unityVersion;
    bool sending;

    [Serializable]
    class Report
    {
        public string message, stack_trace, build, platform, unity_version;
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Install()
    {
        // Read once, here on the main thread: the callbacks below can run on any thread.
        build = Application.version;
        platform = Application.platform.ToString();
        unityVersion = Application.unityVersion;

        Application.logMessageReceivedThreaded += OnLog;
        AppDomain.CurrentDomain.UnhandledException += (_, e) => Enqueue(e.ExceptionObject.ToString(), "");

        var host = new GameObject("ErrorReporter");
        DontDestroyOnLoad(host);
        host.AddComponent<ErrorReporter>();
    }

    static void OnLog(string condition, string stackTrace, LogType type)
    {
        // No Debug.Log in here: it would call OnLog again.
        if (type == LogType.Exception) Enqueue(condition, stackTrace);
    }

    static void Enqueue(string message, string stackTrace)
    {
        if (Pending.Count >= MaxQueued) return;
        Pending.Enqueue(JsonUtility.ToJson(new Report
        {
            message = message, stack_trace = stackTrace, build = build, platform = platform, unity_version = unityVersion
        }));
    }

    void Update()
    {
        if (!sending && Pending.TryDequeue(out var json)) StartCoroutine(Send(json));
    }

    IEnumerator Send(string json)
    {
        sending = true;
        using (var request = new UnityWebRequest(Endpoint, UnityWebRequest.kHttpVerbPOST))
        {
            request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
            request.downloadHandler = new DownloadHandlerBuffer();
            request.SetRequestHeader("Content-Type", "application/json");
            yield return request.SendWebRequest();
        }
        sending = false;
    }
}

The queue is bounded, so an exception thrown every frame can't use up memory, and a report still queued when the game quits is lost. That's the usual trade: a game can't hold up quitting to finish a network request.

One more kind never reaches either hook: a Task that fails while nothing awaits it. It's reported only when the garbage collector finalizes the task, through TaskScheduler.UnobservedTaskException, which can be much later or never. Await your tasks, or hook that event as well.

What a useful report carries

A stack trace says where. To fix the bug you also need to know which build it was, and what the player was doing:

  • The build's version (Application.version), so you can tell an old bug from a new one and see whether a fix shipped
  • The platform and Unity version: a bug that only happens on Android, or only since an engine upgrade, says so straight away
  • The device model and operating system (SystemInfo.deviceModel and SystemInfo.operatingSystem), for the bugs that only happen on one phone or one GPU
  • What led up to it: recent log lines, the scene or level, the last few things the player did
  • A player id rather than an email address, so you can count how many players hit it and find their other reports

Read Application and SystemInfo values once, on the main thread at startup, and keep them, as the reporter above does: many of them can't be read from another thread. Record what only your game knows (the level, the save slot, a purchase) when it happens, so it's there when an exception arrives.

Stack traces from release builds

In the Editor and in development builds, a stack trace has file names and line numbers. A release build compiled with IL2CPP (the only option on iOS and WebGL, and required for 64 bit Android) usually has method names only. That's often enough, but not when a method is long.

Unity 2022.2 and newer have a Player setting for it: IL2CPP Stacktrace Information, under Other Settings, set to Method Name, File Name, and Line Number. It makes the build a little bigger. On older versions, keep methods short enough that a name is a location.

Mark which frames are your own code. Most of a Unity stack trace is the engine and its libraries, and the first frame of your own code is usually where the fix goes.

Don't report from the Editor

Otherwise every exception in Play mode is reported from every developer's machine, mixed in with the players'. Report only from builds players run: Debug.isDebugBuild is true in the Editor and in development builds, or name the environment yourself (production, staging) and send only from those. When you want to test reporting from the Editor, turn it on in code, on purpose, so it isn't left on by accident.

What C# can't catch

Everything above catches managed exceptions: ones thrown in C# and caught by Unity or the runtime while the game kept running. Some failures never become one:

  • A native crash: a bad pointer in a native plugin, in IL2CPP generated code or in the engine. The process ends at once, and no C# runs.
  • The operating system closing the game, most often on a phone for using too much memory. There's no exception; the game is just gone.
  • A hang: the main thread stuck in a loop or waiting on a lock. Nothing throws, and nothing is logged.

Catching native crashes takes a native crash reporter for each platform, one that writes a crash log and sends it on the next launch. For memory kills and hangs, a cheap signal is to record that a session started and whether it ended cleanly: sessions that never ended are crashes you didn't see.

With the ForgeOps Unity package

The ForgeOps package does the reporter's job: both hooks, a bounded queue, delivery with UnityWebRequest from the main thread. Install it from a git URL in the Package Manager (Window > Package Manager > + > Add package from git URL, then https://github.com/Luke-Popwell/forge-ops-tracker-unity.git), and start it before the first scene loads:

C#, Assets/Scripts/Reporting.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using ForgeOpsTracker.Unity;
using UnityEngine;

public static class Reporting
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Start()
    {
        ForgeOpsTrackerClient.Init(c =>
        {
            c.Dsn = "https://<api_key>@getforgeops.net/api/v1/events";
            c.AppNamespacePrefixes = new HashSet<string> { "MyGame" }; // frames from your own code
        });

        // The one kind of exception the package doesn't hear on its own.
        TaskScheduler.UnobservedTaskException += (_, e) =>
        {
            ForgeOpsTrackerClient.CaptureException(e.Exception);
            e.SetObserved();
        };
    }

    public static void SignedIn(string playerId) =>
        ForgeOpsTrackerClient.SetUser(new Dictionary<string, object> { ["id"] = playerId });

    public static void LevelStarted(string level) =>
        ForgeOpsTrackerClient.AddBreadcrumb($"started {level}", "level");
}

In the Editor and development builds the environment is development, which doesn't send, and Init logs one [ForgeOps] Not sending warning saying how to change it. Set c.EnvironmentName = "production" to try it from Play mode.

Unity in ForgeOps, in short

The package (com.forgeops.tracker, Unity 2021.3 and newer, no other dependencies) reports every exception Unity logs and every unhandled exception on your own threads, with the build version, platform, Unity version, the player you set with SetUser, and the last 30 breadcrumbs (Console lines and your own). Reports are grouped into issues, and frames from your own namespaces are marked as yours.

It doesn't catch native crashes, memory kills or hangs, and it doesn't hook TaskScheduler.UnobservedTaskException itself. It's been tested in a real Editor on Unity 6; IL2CPP device builds haven't been, so try one before relying on it.

Read more:

More guides

Try it on your own project

Start free, and turn on strict privacy in the project's settings before your first error arrives.