Diagnosing "it stutters" tickets ③ — capturing performance data at the moment it happens

How to keep evidence of performance problems on QA machines with no Profiler attached. Lightweight always-on sampling with ProfilerRecorder, and putting the spike on the same timeline as the video.

  • unity
  • performance
  • qa

Diagnosing "it stutters", part 3 of 3 — ① The four causes · ② Measuring in practice · ③ Capturing the moment

Part 1 covered classification and part 2 covered measurement. One problem is left. Performance problems happen where there is no Profiler. QA machines, internal test builds, publisher review environments. By the time you get an "it stuttered" report and go attach instrumentation, it's already too late.

The direction of the answer is the same as in the bug reporting seriesalways record, keep only what you need. This time it's performance metrics instead of logs.

The condition for always-on sampling: the sampler must not become the bottleneck

Code that measures performance while eating performance defeats itself. That's why attaching the whole Profiler is out, and there are three conditions:

  • per-frame cost has to be negligible (a few microseconds)
  • no GC allocation — a sampler that causes GC spikes is the worst case
  • it has to work in shipped and QA builds

The ProfilerRecorder API fits. No Profiler window; you pick specific counters and read them at runtime.

Minimal implementation — a ring buffer of metrics

using Unity.Profiling;
using UnityEngine;

public class PerfSampler : MonoBehaviour
{
    // Frame time via the Time API, GC via ProfilerRecorder — covers CPU/GC from
    // part 1's four classes. GPU time is collected separately with FrameTimingManager
    // (see part 2); draw calls and other counters can be added the same way as _gcMemory.
    ProfilerRecorder _gcMemory;

    const int Capacity = 1800;          // 60fps × 30s
    readonly float[] _frameTimes = new float[Capacity];
    readonly long[]  _gcDeltas   = new long[Capacity];
    int _head;
    long _lastGc;

    void OnEnable()
    {
        _gcMemory = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Used Memory");
        // ⚠️ Some counters are unavailable in release builds — if Valid is false,
        //    don't put the value in a report (an unsupported 0 reads as a measured 0)
        if (!_gcMemory.Valid) Debug.LogWarning("[Perf] GC counter unavailable in this build");
    }

    void OnDisable()
    {
        _gcMemory.Dispose();
    }

    void Update()
    {
        // Fixed arrays + a wrapping index — no allocation, even over hours
        _frameTimes[_head] = Time.unscaledDeltaTime * 1000f;
        long gc = _gcMemory.LastValue;
        _gcDeltas[_head] = gc - _lastGc;   // a frame where this goes negative = GC ran
        _lastGc = gc;
        _head = (_head + 1) % Capacity;
    }
}

The structure is identical to the log ring buffer. Keep the last 30 seconds in memory, continuously overwritten, and commit to disk the moment someone sees a problem.

Leaving a marker on the spike

Arrays of numbers alone make it hard to find "which moment was the problem" later. If you log the moment frame time crosses a threshold, the log ring buffer and the performance buffer end up pointing at the same event.

// Detect a jump relative to recent frames — absolute thresholds false-positive across devices
if (_frameTimes[_head] > _recentAverage * 3f && _frameTimes[_head] > 33f)
    Debug.LogWarning($"[Perf] frame spike: {_frameTimes[_head]:F1}ms (avg {_recentAverage:F1}ms)");

Once again, the shared time axis is the point

A performance graph by itself only tells you "there was a spike at second 17." It has to be attached to what was on screen at that moment — did a boss appear, did the inventory open — before a hypothesis about the cause emerges.

So the destination merges with the bug reporting series. When video, logs, and performance metrics are aligned on one timeline, clicking the spike on the frame graph brings up the screen and the logs from that instant. An "it stutters" report becomes a diagnosable ticket: "a 47ms spike in this scene while GC ran."


We're building this pipeline as Rekon's performance timeline — play video and FPS/memory graphs scrubbed on one axis. Whether you build it yourself or use a tool, the series comes down to one conclusion. Performance tickets are an evidence problem too, and evidence can only be collected at the moment it happens.