Enabling Out-of-the-Box Performance Insights in Unity Games with the Sentry SDK
The Sentry Unity SDK has been effective for crash reporting, including:
Support for line numbers in C# exceptions on IL2CPP (in release mode!)
Capturing native crashes on Windows, macOS, Linux, Android and iOS
Context set via C# would show up on any type of event, including minidumps, debug symbols are magically uploaded when you build the game with the editor.
We are confident that we have the best crash-reporting solution out there. Now we were looking towards offering some out-of-the-box insights into the game’s performance.
Right out of the gate, we hit the first question: What would auto-instrumentation for Unity games look like?
Adapting Sentry’s performance UX for Unity
Sentry provides visualization of span trees, while the instrumentation for mobile and web is based on screen rendering. We wanted to take those concepts and apply them to Unity. As a result, we scoped the initial instrumentation to the game’s startup procedure and scene loading. Every game starts at some point. And every game loads a scene, no matter how big or small. We might not be able to give insights into the entire game yet, but we can show every developer what Sentry could offer right after installing the package.
Our ideal scenario would be something that would work out of the box with little to no setup from the user. Here’s a sneak peek: This is what the Unity SDK‘s auto-instrumentation offers OOTB right now. Without a single line of code. For all Unity games.
But what is even more interesting is how we built this and what it means for the future of game dev performance support, which is why we’re really excited, and you should be too.
Introducing Sentry SDK for Unity: a multi-platform tool
Unity games run on basically all platforms. To support that, the Sentry SDK for Unity became an SDK of SDKs. It ships and integrates via P/Invoke (FFI) with whatever SDK is native for the targeted platform. Running on iOS? Not a problem, we include the Sentry SDK for Apple to have you covered. Same for Android and native Linux and Windows.
After all, this is how we achieved the native crash-capturing support. What those SDKs also have in common, other than powering the Unity SDK, is that they all provide some form of auto instrumentation.
Unfortunately, this has limited use. A key factor in Unity’s success is its platform abstraction: developers are free from worrying about platform specifics, which allows them to focus solely on Unity internals. Unity games are typically embedded within a super thin launcher, so concepts like navigation events and UI activities from the underlying platform are generally unfamiliar to them. For instrumentation to be truly helpful and actionable, the SDK would need to operate directly within Unity.
Understanding the Unity lifecycle: finding key points for instrumentation
Games work in a super tight loop, typically updating anywhere from 30 to 60 times per second, but the sky is the limit. Creating a span to measure every single tick is not feasible. We needed to look at some overarching actions, like some set of logical operations we wanted to capture.
The challenge of defining transactions and spans
Sentry has two working concepts to measure how long something takes: Transactions and Spans. Transactions are single instances of an activity or service, like loading a page or some async task. Spans are individual measurements that are nested within a transaction.
Conceptionally, we’re trying to find places to start and stop a giant stopwatch for bigger and very specific actions that we want to measure. Then, we are looking for sub-tasks within that action that we could capture with smaller stopwatches. But how does a transaction fit within the frame of a game? What instance of a service that is already built into the engine could a transaction represent?
For all its features, Unity is still a blank canvas for you to create any kind of game. That means there are, other than the general lifecycle, not very many fixed points that the SDK could hook into to start and stop a span. There are a whole bunch of one-time events like button clicks, but how would the SDK hook into whatever happens behind the button click? How would the SDK know when to finish the span?
Universal events in Unity: startup and scene loading
All games need to start, and the startup procedure is the same for all Unity games. It consists of loading the systems, the splash screen (if applicable), and the loading of the initial scene. Scene loading, in general, is another great fixed point; everything within Unity exists within the context of a scene. Some games load scenes additionally, some games swap them, and some games only ever have one scene. But at least that one scene gets loaded during startup.
With this, we had our transaction hooks. We know when the startup is starting and finished. We can also hook into the scene manager to time scene load and scene load finish events.
Adding granularity: populating transactions with spans
Now that we have our overarching operation, we began looking for smaller actions that happen in scene loads. Looking towards Unity’s lifecycle helps us out once more. The initialization happens for every GameObject during its creation or, if it is an initial part of the scene, during the scene’s loading. For all GameObjects the one method that gets invoked is the Awake
call. And that’s the user’s code, which is exactly what we would like to instrument. That’s the code the user controls and where we want to highlight performance opportunities or bottlenecks. But how would the SDK instrument non-SDK code without asking the user to do it for us?
Intermediate Language (IL) weaving - The art of… insert pun
When working on your Unity game, you typically write your code using C#. Even though it later gets compiled to platform native code via IL2CPP, at some point in the build process that C# code gets compiled to Intermediate Language (IL). Even though Unity might transpile the IL to C++ later on, that IL is still there, somewhere.
We managed to hook the SDK into the build pipeline to modify the generated Assembly-CSharp.dll
.
Let’s say we have a very simple MonoBehaviour
for demonstration purposes:
using UnityEngine; public class BlogMaterial : MonoBehaviour { private void Awake() { Debug.Log("Hello World!"); } }
This MonoBehaviour compiles to the following IL:
.class public auto ansi beforefieldinit BlogMaterial extends [UnityEngine.CoreModule]UnityEngine.MonoBehaviour { // Methods .method private hidebysig instance void Awake () cil managed { // Method begins at RVA 0x2160 // Header size: 1 // Code size: 11 (0xb) .maxstack 8 // Debug.Log((object)"Hello World!"); IL_0000: ldstr "Hello World!" IL_0005: call void [UnityEngine.CoreModule]UnityEngine.Debug::Log(object) // } IL_000a: ret } // end of method BlogMaterial::Awake } // end of class BlogMaterial
Writing code that writes code with Cecil
We want to wrap whatever is going on inside the Awake
with a span. For this, we created some helpers that are accessible from anywhere inside the user’s code.
/// <summary> /// A MonoBehaviour used to provide access to helper methods used during Performance Auto Instrumentation /// </summary> public partial class SentryMonoBehaviour { public void StartAwakeSpan(MonoBehaviour monoBehaviour) => SentrySdk.GetSpan()?.StartChild("awake", $"{monoBehaviour.gameObject.name}.{monoBehaviour.GetType().Name}"); public void FinishAwakeSpan() => SentrySdk.GetSpan()?.Finish(SpanStatus.Ok); }
Initially, we did this change manually and took a look at the resulting IL.
private void Awake() { SentryMonoBehaviour.Instance.StartAwakeSpan(this); Debug.Log("Hello World!"); SentryMonoBehaviour.Instance.FinishAwakeSpan(); }
// Methods .method private hidebysig instance void Awake () cil managed { // Method begins at RVA 0x22f7 // Header size: 1 // Code size: 32 (0x20) .maxstack 8 // SentryMonoBehaviour.Instance.StartAwakeSpan(this); IL_0000: call class [Sentry.Unity]Sentry.Unity.SentryMonoBehaviour [Sentry.Unity]Sentry.Unity.SentryMonoBehaviour::get_Instance() IL_0005: ldarg.0 IL_0006: callvirt instance void [Sentry.Unity]Sentry.Unity.SentryMonoBehaviour::StartAwakeSpan(class [UnityEngine]UnityEngine.MonoBehaviour) // Debug.Log("Hello World!"); IL_000b: ldstr "Hello World!" IL_0010: call void [UnityEngine.CoreModule]UnityEngine.Debug::Log(object) // SentryMonoBehaviour.Instance.FinishAwakeSpan(); IL_0015: call class [Sentry.Unity]Sentry.Unity.SentryMonoBehaviour [Sentry.Unity]Sentry.Unity.SentryMonoBehaviour::get_Instance() IL_001a: call instance void [Sentry.Unity]Sentry.Unity.SentryMonoBehaviour::FinishAwakeSpan() // } IL_001f: ret } // end of method BlogMaterial::Awake
But the .dll
is not just a text file. So, how do we modify this? Luckily, there are libraries like Cecil around that we can build on and that do the heavy lifting for us. Cecil basically turns this into something akin to painting by numbers:
1. Compile your code to create the “baseline”
2. Modify the source code to your desired end result
3. Let the compiler do what it does best - translate your C# code into IL
4. Inspect the difference between the baseline and the end result
5. Use Cecil to recreate the change
The code that modifies the Awake
and adds the StartSpan
functionality is three lines:
// Adding in reverse order because we're inserting *before* the 0ths element processor.InsertBefore(method.Body.Instructions[0], processor.Create(OpCodes.Callvirt, startAwakeSpanMethod)); processor.InsertBefore(method.Body.Instructions[0], processor.Create(OpCodes.Ldarg_0)); processor.InsertBefore(method.Body.Instructions[0], processor.Create(OpCodes.Call, getInstanceMethod));
You can inspect the whole setup of reading, modifying, and writing the IL here.
And the result is this Trace View for every Unity game out-of-the-box, without the user having to write a single line of code.
What do we have now?
With this IL weaving setup, we accomplished two goals:
Immediate, visible performance value: Developers see auto-instrumented performance insights without adding extra code.
A foundation for future expansion: We proved it’s viable to inject custom SDK functionality that wraps user code, enabling future opportunities for auto-instrumentation.
Try Sentry’s Unity SDK today
This setup opens the door for even more instrumentation possibilities. For instance, UnityWebRequests could be instrumented automatically, or we could explore adding spans to button clicks by timing actions around them.
If you’re a game developer, give the Unity SDK a try and let us know if you have any feedback on GitHub or Discord.