Install

Unity

Add AnyAnalytics to a Unity game with one C# file.

One C# file adds AnyAnalytics to any Unity game. It has no dependencies, keeps events on disk while the player is offline, and sends them in batches in the background.

Install

  1. Add the file

    Create AnyAnalytics.cs with the code below. Your project's setup page shows it with your write key filled in.

  2. Add the component

    Save it under Assets/Scripts, then add the component to a GameObject in your first scene. It survives scene loads.

  3. Press Play

    A $session_start event is sent when the component wakes up. It shows up in Live events within about 10 seconds.

AnyAnalytics.csc#
// AnyAnalytics.cs: queues events on disk and posts them to AnyAnalytics.
// Every event gets a uuid when tracked, so a retried batch is never counted twice.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

public class AnyAnalytics : MonoBehaviour
{
    const string Host = "https://api.anyanalytics.org";
    const string WriteKey = "vxw_YOUR_WRITE_KEY";
    const float FlushInterval = 10f;
    const int MaxQueue = 1000; // oldest events are dropped beyond this
    const int MaxBatch = 100;

    static AnyAnalytics instance;
    static string distinctId;
    static readonly string sessionId = Guid.NewGuid().ToString();
    readonly List<string> queue = new List<string>(); // one JSON event per entry
    List<string> inFlight = new List<string>();
    string queuePath;
    bool flushing, warnedFull;
    int batchSize = MaxBatch;
    float backoff, retryAt;

    void Awake()
    {
        if (instance != null) { Destroy(gameObject); return; }
        instance = this;
        DontDestroyOnLoad(gameObject);
        distinctId = PlayerPrefs.GetString("anyanalytics_id", "");
        if (distinctId == "")
        {
            distinctId = Guid.NewGuid().ToString();
            PlayerPrefs.SetString("anyanalytics_id", distinctId);
        }
        // Events that weren't delivered last time (one JSON object per line)
        queuePath = Path.Combine(Application.persistentDataPath, "anyanalytics_queue.jsonl");
        if (File.Exists(queuePath))
            foreach (var line in File.ReadAllLines(queuePath)) if (line.Length > 0) queue.Add(line);
        Track("$session_start");
        StartCoroutine(FlushLoop());
    }

    public static void Track(string eventName, Dictionary<string, object> properties = null)
    {
        if (instance == null) { Debug.LogWarning("[AnyAnalytics] Add the component to a GameObject first."); return; }
        var context = new Dictionary<string, object>
        {
            { "platform", Application.platform.ToString() },
            { "app_version", Application.version },
            { "os", SystemInfo.operatingSystem },
            { "device", SystemInfo.deviceModel },
            { "locale", CultureInfo.CurrentCulture.Name },
        };
        var payload = new Dictionary<string, object>
        {
            { "uuid", Guid.NewGuid().ToString() }, // kept across retries for deduplication
            { "event", eventName },
            { "distinct_id", distinctId },
            { "session_id", sessionId },
            { "timestamp", Now() },
            { "properties", properties ?? new Dictionary<string, object>() },
            { "context", context },
        };
        instance.Enqueue(Json(payload));
    }

    public static void Identify(string userId, Dictionary<string, object> traits = null)
    {
        var anonymousId = distinctId;
        distinctId = userId;
        PlayerPrefs.SetString("anyanalytics_id", userId);
        Track("$identify", new Dictionary<string, object>
        {
            { "$anon_distinct_id", anonymousId },
            { "$set", traits ?? new Dictionary<string, object>() },
        });
    }

    void Enqueue(string json)
    {
        queue.Add(json);
        if (queue.Count > MaxQueue)
        {
            queue.RemoveRange(0, queue.Count - MaxQueue);
            if (!warnedFull) { warnedFull = true; Debug.LogWarning("[AnyAnalytics] Queue full, dropping the oldest events."); }
        }
        if (queue.Count >= 20) StartCoroutine(Flush());
    }

    IEnumerator FlushLoop()
    {
        while (true)
        {
            yield return new WaitForSecondsRealtime(FlushInterval);
            Save();
            yield return Flush();
        }
    }

    // One request at a time; events leave the queue only once the server accepts them.
    IEnumerator Flush()
    {
        if (flushing) yield break;
        flushing = true;
        while (queue.Count > 0 && Time.realtimeSinceStartup >= retryAt)
        {
            inFlight = queue.GetRange(0, Math.Min(queue.Count, batchSize));
            queue.RemoveRange(0, inFlight.Count);
            var body = "{\"sent_at\":" + Json(Now()) + ",\"batch\":[" + string.Join(",", inFlight) + "]}";
            long code;
            string retryAfter, error;
            using (var request = new UnityWebRequest(Host + "/v1/batch", "POST"))
            {
                request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.SetRequestHeader("Authorization", "Bearer " + WriteKey);
                yield return request.SendWebRequest();
                code = request.responseCode; // 0 = network error
                retryAfter = request.GetResponseHeader("Retry-After");
                error = request.downloadHandler.text;
            }
            if (code >= 200 && code < 300) { backoff = 0; batchSize = MaxBatch; }
            else if (code == 413 && inFlight.Count > 1)
            {
                // Body too large: retry the same events in smaller batches
                batchSize = Math.Max(1, inFlight.Count / 2);
                queue.InsertRange(0, inFlight);
            }
            else if (code == 0 || code == 408 || code == 429 || code >= 500)
            {
                // Offline, timeout, rate limited or server busy: keep the events, back off
                queue.InsertRange(0, inFlight);
                RetryLater(retryAfter);
            }
            else
            {
                // Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
                Debug.LogWarning("[AnyAnalytics] Dropped " + inFlight.Count + " event(s): HTTP " + code + " " + error);
            }
            inFlight.Clear();
            Save();
        }
        flushing = false;
    }

    // Exponential backoff with jitter (1s doubling to 60s); Retry-After wins when sent.
    void RetryLater(string retryAfter)
    {
        backoff = backoff == 0 ? 1f : Math.Min(backoff * 2, 60f);
        retryAt = Time.realtimeSinceStartup + (float.TryParse(retryAfter, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)
            ? seconds
            : backoff * UnityEngine.Random.Range(0.5f, 1f));
    }

    void Save()
    {
        var all = new List<string>(inFlight);
        all.AddRange(queue);
        try { File.WriteAllLines(queuePath, all); }
        catch (Exception e) { Debug.LogWarning("[AnyAnalytics] Couldn't save the queue: " + e.Message); }
    }

    void OnApplicationPause(bool paused)
    {
        if (!paused) return;
        Save();
        StartCoroutine(Flush());
    }

    void OnApplicationQuit() => Save(); // sent on next launch, same uuids

    static string Now() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);

    static string Json(object value)
    {
        switch (value)
        {
            case null: return "null";
            case string s:
                return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t") + "\"";
            case bool b: return b ? "true" : "false";
            case IDictionary<string, object> dict:
                var fields = new List<string>();
                foreach (var pair in dict) fields.Add(Json(pair.Key) + ":" + Json(pair.Value));
                return "{" + string.Join(",", fields) + "}";
            case IEnumerable list:
                var items = new List<string>();
                foreach (var item in list) items.Add(Json(item));
                return "[" + string.Join(",", items) + "]";
            case IFormattable number: return number.ToString(null, CultureInfo.InvariantCulture);
            default: return Json(value.ToString());
        }
    }
}
  • Needs Unity 2020.2 or newer.
  • Undelivered events are saved to Application.persistentDataPath and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.

Track events

AnyAnalytics.Track is static, so you can call it from any script once the component has woken up:

LevelManager.csc#
using System.Collections.Generic;

AnyAnalytics.Track("level_completed", new Dictionary<string, object>
{
    { "level", 5 },
    { "score", 1200 },
    { "duration", 82 },
});

Property values can be strings, numbers, booleans, lists and nested dictionaries. Events are sent every 10 seconds, as soon as 20 are queued, and when the app is paused. Anything left when the game quits is sent on the next launch.

Identify players

Each install gets a random player id, saved in PlayerPrefs. When the player signs in to your backend or platform account, switch to your own id:

Login.csc#
// After the player signs in to your backend / platform account
AnyAnalytics.Identify(player.Id, new Dictionary<string, object>
{
    { "name", player.DisplayName },
    { "platform", "steam" },
});

Their earlier anonymous events are linked to the same player, and the traits are saved on their profile. The new id is remembered across launches.

Levels and the Game report

The Game report builds level funnels from events named level_start, level_complete and level_fail, each with a level property:

LevelManager.csc#
using System.Collections.Generic;

// When the player starts a level
AnyAnalytics.Track("level_start", new Dictionary<string, object> { { "level", 3 } });

// When they finish it, or fail it
AnyAnalytics.Track("level_complete", new Dictionary<string, object>
{
    { "level", 3 },
    { "score", 1200 },
});
AnyAnalytics.Track("level_fail", new Dictionary<string, object> { { "level", 3 } });

See Game events for every event and property the report reads, including errors and crashes.

Sessions and context

Each launch of the game is one session. Every event carries the platform, the app version from Player Settings, the operating system, the device model and the locale, so you can break any report down by them.

Tip

Calls made before the component's Awake log a warning and are dropped. Put the component in your first scene so it's ready before any of your own scripts track.