The official Unity SDK for GameMetric — a LiveOps & analytics platform for games. Non-blocking event logging, automatic crash reporting, Remote Config, and A/B testing, built offline-first with a strict focus on zero frame-time impact.
- 🚀 Non-blocking — logging never touches the network or does JSON work on your frame.
- 📦 Offline-first — events are cached durably and re-sent when connectivity returns.
- 🎛️ Remote Config & A/B testing — change balance and run experiments without shipping a build.
- 💥 Crash & error reporting — uncaught exceptions captured automatically, deduplicated.
- 🔌 Zero-dependency — pure C#, no third-party packages.
- Unity 2020.3 or newer.
- A GameMetric account and project API key (see below).
Install via the Unity Package Manager → Add package from git URL…:
https://github.com/Protemir/gamemetric-unity-sdk.git
Or add it to your project's Packages/manifest.json:
{
"dependencies": {
"dev.gamemetric.sdk": "https://github.com/Protemir/gamemetric-unity-sdk.git"
}
}- Sign up at https://gamemetric.dev.
- Create a project for your game from the dashboard.
- Open Settings → Project Settings and copy your project's API Key.
- Provide it to the SDK, either:
-
In the Editor — open Edit → Project Settings → GameMetric, paste the API Key (and optionally a Game Version), then call
GameMetric.Initialize()with no arguments; or -
In code — pass it directly:
GameMetric.Initialize("YOUR_API_KEY");
-
The API key identifies your project during ingestion. It is safe to ship in a client build (it only permits sending events for that one project).
using System.Collections.Generic;
using GameMetricSDK;
public class Bootstrap : MonoBehaviour
{
void Start()
{
// 1. Start a session (new SessionId, device metadata, background delivery).
GameMetric.Initialize("YOUR_API_KEY");
// 2. Log a gameplay event with arbitrary properties.
GameMetric.LogEvent("level_complete", new Dictionary<string, object>
{
{ "level", 7 },
{ "duration_seconds", 42.5 },
{ "used_powerup", true },
});
// 3. Log an in-app purchase (revenue analytics).
GameMetric.LogMonetization("com.game.gems_pack", 4.99, "USD");
// 4. Log progression through your game.
GameMetric.LogProgression(ProgressionStatus.Complete, "world_1.level_3");
// 5. Read remote config (cached on disk, so the first frame gets real values).
int startingGold = GameMetric.GetConfig<int>("starting_gold", 100);
float spawnRate = GameMetric.GetConfig<float>("spawn_rate", 1.0f);
// 6. React live when config changes during a session.
GameMetric.OnConfigUpdated += () =>
spawnRate = GameMetric.GetConfig<float>("spawn_rate", 1.0f);
// 7. Branch on the player's A/B variant (sticky per user, resolved server-side).
if (GameMetric.GetVariant("price_test") == "variant_a")
ShowDiscountedStore();
}
}Manually report a handled exception:
try { Risky(); }
catch (System.Exception e) { GameMetric.LogError(e.Message, e.StackTrace); }Identify a known user (persists across sessions):
GameMetric.SetUserId("account-12345");A deeper look for tech leads evaluating the SDK.
LogEvent is designed to do no work that scales with your frame. On the hot
path it:
- Rents a reusable event object from a bounded, thread-safe object pool
(
EventPool), so steady-state logging allocates nothing for the event itself. - Copies the caller's fields, and enqueues onto a lock-free
ConcurrentQueue.
No network, no JSON serialization, and no locks happen inline. All heavy work is
deferred to a hidden DontDestroyOnLoad runner and, where possible, pushed off the
main thread onto the .NET thread pool.
Delivery failures never lose data:
EventStoreis a durable NDJSON offline cache underApplication.persistentDataPath. All file I/O (append / read / trim) runs on a pool thread viaTask.Run, and the flush coroutine awaits it without blocking a frame. Reads stream (they stop after the requested count instead of loading the whole file), and rewrites go through a temp file + atomic replace (File.Replace), so a crash mid-write can never corrupt the cache. An in-memory line count keeps the "is there anything to send?" check off the disk entirely and only triggers a trim when the cap is actually exceeded.RemoteConfigCachepersists the last good config the same way (atomic temp-file replace on a pool thread) and is loaded synchronously atInitialize, so the very first frame reads real values instead of fallbacks — the classic cold-start problem is solved, with a fresh network fetch layered on top (stale-while-revalidate).
On pause/quit the queue is flushed to the cache synchronously, so nothing is lost when the app is backgrounded or closed.
- Batching. Events are sent as a JSON array, up to 256 per request, on a 10 s timer or as soon as 50 are queued — bounded, retry-friendly chunks.
- Idempotency. Every event carries a client-generated
event_id, fixed at log time and preserved across every retry, so a batch that was delivered but whose response was lost is deduplicated server-side instead of inflating counts. - Exponential backoff. After a retryable failure (network error,
5xx,429) the flush cadence backs off2s → 4s → 8s → 16s → 32s → 60s(capped) and resets on the next success. Non-retryable4xxis dropped with a warning. A hung socket is bounded by a per-request timeout, and a captured crash triggers a priority flush on the next frame (bypassing the timer) so it ships before a possible termination. - Optional gzip compression of the request body.
A session starts at Initialize. After the app is backgrounded past a timeout
(default 30 min), the next resume closes the old session with a back-dated
session_end (carrying duration_seconds) and opens a fresh one — so session
counts and durations stay accurate instead of one id spanning the whole process.
Uncaught exceptions and Debug.LogError / Debug.Assert are captured
automatically as error events — including errors thrown on background threads
and the Job System (capture hooks logMessageReceivedThreaded). Identical errors
(same stack) are deduplicated by a stable signature hash and counted, so a
per-frame crash loop becomes a couple of counted events instead of hundreds — the
delivery pipeline is never flooded.
Native crashes (iOS signal / Android SIGSEGV / il2cpp hard-crash) kill the
process, so they can't be sent in the moment. A platform crash handler writes a
small JSON record under persistentDataPath/gamemetric/native-crashes/, and on the
next launch the SDK emits each as a fatal error event (is_native: true,
back-dated to the crash time) through the normal pipeline, then deletes it. Bundled
platform writers cover the "managed uncaught" layer: Android (a Java
uncaught-exception handler) and iOS (an NSSetUncaughtExceptionHandler),
catching Java/Kotlin and Objective-C crashes the managed hook can't see. Native
signals (SIGSEGV/Mach) and address symbolication are upcoming.
Define GAMEMETRIC_DISABLED (Project Settings → Player → Scripting Define Symbols)
to compile the SDK out entirely: every call becomes a zero-overhead no-op and no
background work starts.
Data collection is on by default; gate it at runtime from your own consent flow:
// e.g. after the player declines analytics in your consent dialog
GameMetric.SetCollectionEnabled(false);While disabled, no events (including crashes and sessions) are collected or sent
and no remote-config fetch runs — and opting out purges data not yet delivered
(the in-memory queue and the offline cache). The choice is persisted and honored
on the next launch, and SetCollectionEnabled works before or after
Initialize(). IsCollectionEnabled reports the current state. For a
build-time kill switch, define GAMEMETRIC_DISABLED.
| Member | Description |
|---|---|
Initialize() / Initialize(apiKey) / Initialize(apiKey, gameVersion, enableDebugLogs, baseUrl) |
Start a session. The parameterless overload reads Project Settings → GameMetric. |
LogEvent(name, parameters = null) |
Queue a custom event. Non-blocking; parameters become the event's JSON properties (primitives or nested dictionaries/lists). |
LogMonetization(productId, amount, currency, extra = null) |
Log an in-app purchase (amount powers revenue metrics). |
LogProgression(status, progression, details = null, extra = null) |
Log a Start / Complete / Fail progression step. |
LogError(message, stackTrace = null, extra = null) |
Manually report a handled exception (uncaught ones are captured automatically). |
GetConfig<T>(key, fallback = default) |
Typed remote-config read (string / bool / int / long / float / double). |
TryGetConfig<T>(key, out value) |
Typed read that reports presence — distinguishes a real value from a fallback. |
GetConfigString/Int/Double/Bool(key, fallback) |
Convenience typed getters. |
GetVariant(experimentName, fallback = null) |
The player's assigned A/B variant, to branch game logic. |
TryGetVariant(experimentName, out variant) |
Variant lookup that reports enrollment. |
OnConfigUpdated (event) |
Raised on the main thread when a fetch changes config values. |
FetchRemoteConfigAsync() |
Force a remote-config refresh (usually unnecessary — auto-fetch is on by default). |
SetUserId(userId) |
Override the anonymous install id with your own stable id (persisted). |
SetCollectionEnabled(enabled) |
Consent / opt-out switch (persisted). Disabling stops all collection and purges undelivered data. |
Flush() |
Request an immediate delivery attempt. |
IsInitialized / SessionId / UserId / IsCollectionEnabled |
Current SDK state. |
- iOS native crash writer (Obj-C layer) — a bundled
.mmplugin installs anNSSetUncaughtExceptionHandlerthat catches uncaught Objective-C exceptions and writes them into the v1.2.0 handoff pipeline (chaining to any previous handler), mirroring the Android Java writer. Native signals (SIGSEGV/Mach) and symbolication remain upcoming. Validate on a device build before relying on it.
- Android native crash writer (Java layer) — a bundled uncaught-exception
handler (
dev.gamemetric.sdk.GameMetricCrashHandler) catches uncaught Java/Kotlin exceptions, including ones from third-party Android SDKs / JNI that the managed hook can't see, and writes them into the v1.2.0 handoff pipeline (chaining to Unity's own handler). Android NDK signals (SIGSEGV/il2cpp), iOS, and symbolication remain upcoming. Validate on a device build before relying on it.
- Native crash handoff (managed pipeline) — the SDK now reads native crash
records left on disk by a platform handler (under
persistentDataPath/gamemetric/native-crashes/) and, on the next launch, emits each as a fatalerrorevent (is_native: true, back-dated to the crash time) through the normal delivery pipeline, then deletes the file. This ships the platform-agnostic contract + pickup; the platform native writers (iOS/Android/ il2cpp) and symbolication follow.
- Background-thread crash capture — error/exception capture now hooks
logMessageReceivedThreaded, so exceptions thrown on background threads or the Job System are captured too (previously only the main thread). The capture path is thread-safe: a per-thread re-entrancy guard and a monotonic clock that avoids main-thread-only Unity APIs.
- WebGL offline cache — on WebGL the offline cache is now in-memory instead of
writing to
Application.persistentDataPath(a browser virtual filesystem that isn't persisted across reloads withoutFS.syncfs, on a single-threaded runtime where the disk path's background I/O can't run). In-session retry works; un-sent events don't survive a page reload — the honest behavior for the web. The remote-config disk cache is likewise skipped on WebGL (config re-fetches on load).
- Nested event properties — property values can now be nested dictionaries and
lists, serialized as real JSON objects/arrays (previously only primitives were
supported; anything else fell back to a
ToString()that produced garbage). - Exact numeric round-trip —
double/floatproperties now serialize withG17/G9, which round-trip exactly, instead of the historically unreliableR.
- Backoff jitter — the retry backoff now applies ±20% jitter to each delay, so a fleet of clients that failed together (e.g. during a backend outage) don't all retry in lockstep and hammer the server the moment it recovers.
- Backoff fix — a manual
Flush(), the startup flush, and the pause-triggered flush now respect the exponential-backoff cooldown instead of bypassing it, so they can't hammer a server the SDK has already backed off from. Queued events stay cached and deliver on the next eligible cycle; captured crashes still flush with priority.
- Privacy / consent API —
SetCollectionEnabled(bool)andIsCollectionEnabledlet you opt players in or out of collection at runtime. The choice is persisted; while disabled the SDK collects and sends nothing (events, crashes, sessions, remote-config), and opting out purges data not yet delivered.
Initial public release.
- Event logging — non-blocking
LogEventwith pooled event objects and a lock-free queue;LogMonetizationandLogProgressionhelpers. - Sessions — automatic
session_start/session_endwith duration and a configurable background timeout that opens a new session on long resumes. - Offline-first delivery — durable NDJSON cache with atomic writes and
off-main-thread I/O; batching, exponential backoff, per-request timeouts, and
optional gzip; client-generated
event_idfor idempotent, dedup-safe resends. - Remote Config — typed
GetConfig<T>/TryGetConfig<T>, an on-disk cache loaded synchronously at startup (no cold-start fallbacks), background auto-refresh, and anOnConfigUpdatedevent for live reactions. - A/B testing —
GetVariant/TryGetVariantwith deterministic, sticky, server-side assignment; every event auto-tagged with the player's variant. - Crash & error analytics — automatic capture of uncaught exceptions with signature-based deduplication and priority delivery.
- Editor integration — a Project Settings panel for the API key plus a "Send Test Event" button to verify the setup end-to-end.
- Zero dependencies, Unity 2020.3+, and a
GAMEMETRIC_DISABLEDswitch to compile the SDK out completely.
- Website & dashboard: https://gamemetric.dev
- Demo scene: install Demo Scene from the package's Samples in the Package Manager.