UPM package that captures managed C# exceptions, session heartbeats, analytics
events, breadcrumbs, and player bug reports in a Unity game and uploads them to a
Tombstack ingestion endpoint with a per-game SDK token. The native crash core
(../native/) reports native crashes through the same endpoints once
integrated. Follows the AnkleBreaker com.anklebreaker.* convention and C# naming standard.
After Tombstack.Init (or zero-code auto-init from the config asset), the SDK handles most
cases with zero further integration:
| Capability | Automatic? | How |
|---|---|---|
| Unhandled exceptions (main + background threads) | ✅ Automatic | Application.logMessageReceivedThreaded, deduped (≤1 report per signature per 60s; repeats become a counter breadcrumb) |
Unobserved Task exceptions |
✅ Automatic | TaskScheduler.UnobservedTaskException (observed + reported, never escalated) |
| AppDomain unhandled exceptions | ✅ Automatic | AppDomain.CurrentDomain.UnhandledException (write-ahead persisted before the process dies) |
| Errors / warnings / logs as breadcrumbs | ✅ Automatic | Every Unity log line → 50-entry ring, attached to crashes & bug reports |
| Player log upload on crash | ✅ Automatic | Rolling ~512 KB session.log, PUT to a presigned URL after the crash report's 2xx |
| Screenshot attachment | ✅ Automatic (bug reports; opt-in on exceptions) | End-of-frame capture, downscaled (config cap, default 1280 px), uploaded via the presigned screenshotUpload after the report's 2xx — ON by default for bug reports; exception screenshots are opt-in + throttled |
| Unclean shutdown (hard crash / OOM kill / force quit) | ✅ Automatic (next launch) | session.lock marker + preserved previous-session.log, reported as signature unclean-shutdown |
| Session heartbeats / CCU | ✅ Automatic | Every N seconds (default 60) |
| Per-session frame stats (0.11+) | ✅ Automatic | Each heartbeat carries the interval's fpsAvg / slowFramePct (> 33.4 ms) / hitchCount (> 250 ms) / worstFrameMs — sampled allocation-free, omitted when no frame ran (headless servers) |
| App-hang detection (0.11+) | ✅ Automatic | Background watchdog; a main-thread stall > threshold (default 5s) reports one tmb.app_hang event (duration, scene, threshold) on recovery + a Warning breadcrumb — ≤1/min; no cross-thread stack (hangs group by scene) |
| Offline durability + retry | ✅ Automatic | Write-ahead queue, exponential backoff, next-launch retry |
| Event + metric batching (§16) | ✅ Automatic | TrackEvent/TrackMetric accumulate in a bounded, preallocated, drop-oldest buffer (cap 256) and flush as one batch on count ≥ 50 / age ≥ 10s / near-full / pause / quit / pre-crash |
| Player identification | One-liner | Tombstack.SetUser("user-123", steamId) |
| Analytics events (batched) | One-liner | Tombstack.TrackEvent("level_complete", props) |
| Numeric metrics (batched) | One-liner | Tombstack.TrackMetric("tickrate", 60, "hz") |
| Player bug reports (log attached automatically) | One-liner | Tombstack.ReportBug("…", category) |
| Manual breadcrumbs | One-liner | Tombstack.AddBreadcrumb("…", level, category) |
| Caught-but-interesting exceptions | One-liner | Tombstack.ReportException(ex) |
| Server-triggered log pull — client honouring | ✅ Automatic | The heartbeat ack carries pull requests; a consenting, targeted client uploads its rolling session log via the existing presigned-log path, off-thread. A non-consenting client never uploads. |
| Server-side log pull (request a player's logs) | One-liner | Tombstack.RequestPlayerLogs(...) / Tombstack.OnAnomalousDisconnect(userId, reason) (write-scoped server token) |
Every autonomy system has its own toggle on the config asset — all default ON:
| Toggle | Gates |
|---|---|
Auto Capture Exceptions |
Automatic exception capture (Unity log + unobserved Task + AppDomain); manual ReportException always works |
Upload Logs |
The rolling session log + its crash/bug uploads |
Detect Unclean Shutdown |
The session-marker dirty-session detection on next launch |
Auto Scene Breadcrumbs |
Automatic breadcrumbs on scene load / active-scene change |
Send Heartbeats (0.12+) |
The heartbeat loop — OFF logs a warning: live CCU, sessions, crash-free %, fleet, user metadata, and log pulls go dark |
Collect Frame Stats (0.12+) |
The per-heartbeat frame-stats sampler (fpsAvg / slowFramePct / hitchCount / worstFrameMs) |
Detect App Hangs + App Hang Threshold Seconds |
The app-hang watchdog (default 5s, min 2s, 0 disables) |
Capture Screenshot On Bug Report / On Exception |
The two screenshot auto-captures |
At runtime, Tombstack.SetCaptureEnabled(TombstackCapture.X, bool) (0.12+) flips
Exceptions / Heartbeats / Breadcrumbs / FrameStats / AppHangs live. Everything is
additionally consent-gated: with Require Consent enabled, nothing is captured, mirrored, or
reported until Tombstack.SetConsent(true).
Via UPM git URL (public mirror) — Window ▸ Package Manager ▸ + ▸ Add package from git URL…:
https://github.com/AnkleBreaker-Studio/tombstack-unity.git#v0.12.0
Or add to Packages/manifest.json:
Or copy unity/ into your project's Packages/. Requires Unity 6 (6000.0)+ (Mono and IL2CPP).
The package ships an editor plugin (UI Toolkit, forge-dark themed like the web dashboard). It requires a Tombstack account — until you sign in, the editor tooling is inert and a gentle one-time prompt points you to the Hub.
- Sign in —
Window ▸ Tombstack ▸ Sign In(or the Hub's sign-in button). Credentials are exchanged for an editor token stored per-user in EditorPrefs — it never enters the project folder or version control. No account? The window links to the signup page. - Link the project —
Window ▸ Tombstack ▸ Hub→ pick your studio + game (loaded from your account) → Link this project. This mints a per-gametmb_SDK token and writes endpoint + token into theTombstackConfigasset (created atAssets/Tombstack/Resources/TombstackConfig.assetif missing) — the same asset the runtime auto-init reads. The game binding is saved toProjectSettings/TombstackSettings.asset(safe to commit; it contains ids, not secrets). - Watch the dashboard — the Hub's Dashboard tab shows live crash-free %, 24h/7d crash counts, a spike banner, the top-10 signatures (click → opens the signature in the web dashboard), and a 30-day trend. Refresh manually or enable 60s auto-refresh.
- Project Settings —
Edit ▸ Project Settings ▸ Tombstack: endpoint URL override (self-hosted/staging), heartbeat interval and consent defaults (written into the config asset), unlink, and sign out.
- Sign in and link this project from the editor Hub (above) — it mints the SDK token
and writes the config for you. (Manual alternative: mint a
tmb_…token from the game's SDK tokens page on the web dashboard.) - Initialize the SDK, either way:
Zero-code: Create ▸ Tombstack ▸ Config, fill in your token + endpoint, place the
asset under any Resources/ folder named TombstackConfig. It auto-initializes on load
(enable Require Consent to stay silent until SetConsent(true)).
Manual:
using System.Collections.Generic;
using AnkleBreaker.Tombstack;
Tombstack.Init("tmb_…", "https://your-tenant.example.com"); // once, at boot
Tombstack.SetConsent(true); // GDPR / store-policy gate
Tombstack.SetUser("user-123", steamId: "7656119…"); // once auth resolves — upgrades
// the device-derived id in-session (0.16)
// Analytics events (events & funnels screens) — batched, flushed on count/age/pause/quit
Tombstack.TrackEvent("level_complete",
new Dictionary<string, string> { { "level", "3" }, { "difficulty", "hard" } });
// Numeric metrics (time-series, p50/p95/p99) — batched alongside events
Tombstack.TrackMetric("tickrate", 60, "hz");
Tombstack.TrackMetric("rtt_ms", 42.5, "ms");
// Breadcrumbs: the log trail attached to the next crash / bug report
Tombstack.AddBreadcrumb("matchmaking started", BreadcrumbLevel.Info, category: "net");
// In-game feedback form
Tombstack.ReportBug("Quest log empty after load", category: "ui");
// Caught-but-interesting exceptions
try { Load(); } catch (Exception e) { Tombstack.ReportException(e); }- Trigger a test exception — the crash, its signature, and the 30-day trend appear on the game dashboard within seconds.
- Managed exceptions →
Application.logMessageReceivedThreaded(background threads too), plusTaskScheduler.UnobservedTaskExceptionandAppDomain.CurrentDomain.UnhandledException→ SHA-256 signature over the message + normalized top frames →POST /api/v1/ingest/crashes. Identical signatures dedupe to ≤1 report/min (repeats ride the breadcrumb trail as a counter). - Session log: every log line mirrors into a rolling ~512 KB
persistentDataPath/Tombstack/session.log(in-memory buffer, flushed off the main thread at most once per 5s + a final flush on the crash path). Crash and bug reports request a log upload ("log": true); after the 2xx the SDK PUTs the file (text/plain) to the presigneddata.logUpload.urlfrom the response — reusing the upload queue's backoff, never storing the presign across launches. - Unclean shutdowns: Init writes
Tombstack/session.lock; a clean quit (Application.quitting) removes it. If a marker survives to the next launch, the previous session died hard (native crash, OOM kill, force quit) — the SDK reports a synthetic crash (signatureunclean-shutdown, previous session's buildVersion/os/arch) and uploads the preservedprevious-session.log. If the write-ahead queue already held a managed crash from that session, no synthetic report is sent (no double-counting) — the restored crash's retry carries the previous log instead. - Session heartbeats →
POST /api/v1/ingest/heartbeatsevery N seconds (default 60, clamped 15–600) withsessionId/buildVersion/os/arch/userId— feeds the Live Fleet/CCU, Sessions, and Releases screens. - Analytics events →
Tombstack.TrackEvent(name, props)(flat attributes, clamped to the server contract: ≤32 entries, 64-char keys, 512-char values). - Numeric metrics →
Tombstack.TrackMetric(name, value, unit?)for time-series + p50/p95/p99 (e.g. tickrate, RTT, CCU). Finite values only — NaN/Infinity are dropped. Carries the same correlation spine as events so a metric can be sliced by server / match / session / player. - Event/metric batching (§16): events and metrics are NOT sent one request at a time. They
accumulate in a bounded, preallocated, drop-oldest buffer (cap 256) and flush as a single
envelope
{ "sentAtIso", "items": [...] }toPOST /api/v1/ingest/events:batch/…/metrics:batchon count ≥ 50, age ≥ 10s, near-full, app pause/quit, or a pre-crash flush. Each item keeps its ownoccurredAtIso;sentAtIsois the send time (used for clock-skew only). Sends reuse the existing queue + backoff + offline durability. Crashes, bug reports, and heartbeats stay individual (forensic / time-sensitive). - Bug reports →
Tombstack.ReportBug(...)→POST /api/v1/ingest/bug-reports. - Server-triggered log pull (studio- or auto-initiated, consent-gated): a dedicated game server can
request a player's session log when something looks wrong, without the player filing a bug report.
- Server side →
Tombstack.RequestPlayerLogs(target, value, reason)(or theRequestPlayerLogs(target, reason)/OnAnomalousDisconnect(userId, reason)convenience forms) POSTs{ targetType, targetValue, reason }toPOST /api/v1/pull-requestsusing the configured write-scoped server token. An ingest-only client token is rejected (403) server-side — an extracted client token can never request anyone's logs. The pull is queued + audit-logged. - Client side (fully automatic, transparent): each heartbeat ack now carries
data.pendingRequests. For every queued request that targets THIS client (byuserId/sessionId/matchId/serverId) and only while consent is granted, the SDK POSTs/api/v1/pull-requests/{requestId}/fulfilland uploads its current rolling session log through the same presigned-log path used for crashes/bugs — off the main thread, fail-silent, with no gameplay impact. A player who has not granted telemetry consent never uploads. Pulled logs are retention-TTL'd like other telemetry and removed by a data-erasure request.
- Server side →
- Breadcrumbs: every Unity log line + manual
AddBreadcrumb(...)entries land in a fixed 50-entry ring buffer (zero allocations beyond the stored strings) and ride along on crashes and bug reports. - Offline-first + durable: crashes/bugs are written to disk before the first upload
attempt (write-ahead), uploads retry in-session with exponential backoff (2s→32s, 5
attempts), poison payloads (HTTP 4xx) are dropped, and anything undelivered retries on the
next launch from
Application.persistentDataPath/Tombstack(server de-dups by ULID). - Consent-aware: until
SetConsent(true)(when required), nothing is captured or sent — including heartbeats. - Fail-silent: the SDK never throws into game code; internal failures are logged once with
a
[Tombstack]prefix. - Auto-fills
buildVersion(Application.version) andos/arch(platform mapping), cached at Init so capture is safe from any thread. - Pre-init safety (0.9.8+): an explicit
SetEnvironment(...)always wins overInit's parameter / the config asset, even when called before the SDK initializes; pre-initTrackEvent/TrackMetriccalls are buffered (bounded 64, drop-oldest) and replayed through the normal pipeline afterInitwith their original timestamps (consent still gates the replay). - Platforms (0.9.9+):
osmapsRuntimePlatform.Android → "android"andIPhonePlayer → "ios"(previously both fell through toother). - Device identity (0.16.0+) — no anonymous players: at first launch the SDK mints a
persistent device-derived id (
dev_+ 16 hex — SHA-256 of the device identifier salted with your game token, so the raw identifier never leaves the device and the same device is unlinkable across games; stored inTombstack/identity.json) and uses it as theuserIduntilSetUser(realId). The upgrade happens in the same session: a one-shotpriorUserIdrides heartbeats until acked (and crash/bug reports while pending) so the server merges the pre-auth telemetry into the real player.SetUser(null)(logout) reverts to the device id — the SDK never sends an anonymous user.
Tombstack.Init(gameToken, endpoint, heartbeatIntervalSeconds = 60f, environment = null);
Tombstack.SetUser(userId, steamId = null);
Tombstack.SetConsent(bool granted);
Tombstack.TrackEvent(name, Dictionary<string,string> props = null);
Tombstack.TrackMetric(name, double value, string unit = null);
Tombstack.SetSampleRate(name, float rate0to1); // per-name keep-probability for events/metrics
// Granular capture toggles (0.12.0+) — runtime override of the config-asset toggles.
// Exceptions: detaches/reattaches auto-capture (manual ReportException always works);
// Heartbeats: pauses/resumes the loop (CCU/session features go dark while off);
// Breadcrumbs: gates AUTOMATIC crumbs only (manual AddBreadcrumb always works);
// FrameStats / AppHangs: stop/start the sampler / watchdog. Fail-silent, pre-Init-safe.
Tombstack.SetCaptureEnabled(TombstackCapture capture, bool enabled);
// Standard event taxonomy (0.10.0+) — typed wrappers over TrackEvent that emit reserved tmb.*
// names (plain custom events underneath: batching / pre-init buffering / consent / sampling all
// apply). The dashboard auto-recognizes tmb.progression and renders a per-(area, level)
// progression table on the Analytics page.
Tombstack.TrackProgression(ProgressionStatus status, area, level = null, phase = null,
attempt = 0, score = double.NaN); // "tmb.progression"
Tombstack.TrackEconomy(ResourceFlow flow, currency, double amount,
itemType = null, itemId = null, balance = double.NaN); // "tmb.economy"
Tombstack.TrackPurchase(productId, store, currencyCode, long amountMinorUnits); // "tmb.purchase"
Tombstack.TrackAdImpression(network, placement, revenueMicros = double.NaN); // "tmb.ad_impression"
Tombstack.AddBreadcrumb(message, BreadcrumbLevel level = Info, category = null);
Tombstack.ReportException(exception);
Tombstack.ReportBug(message, category = null);
// Deployment environment (production / staging / development / …) — stamped on EVERY payload;
// the dashboard filters every page by it. Defaults to "production"; also settable zero-code via
// the TombstackConfig asset (Project Settings ▸ Tombstack ▸ Environment).
Tombstack.SetEnvironment(environment);
// Per-player custom metadata — merges keys ("displayName" becomes the player's dashboard label);
// null/empty value removes a key. Rides heartbeats; ack-gated so a dropped beat never loses a change.
Tombstack.SetUserMetadata(Dictionary<string,string> metadata);
// Diagnostics snapshot (support overlays / dev HUDs) — readonly struct, no steady-state alloc
TombstackDiagnostics diag = Tombstack.GetDiagnostics();
// diag.Initialized / ConsentGranted / QueuedOutbound / PersistedSidecar /
// diag.LastFlushAgeSeconds / Endpoint / MatchId / ServerId /
// diag.Environment / Region / Hostname / UserMetadataKeyCount
// Multiplayer correlation context (dedicated servers)
Tombstack.SetMatchContext(serverId, matchId);
Tombstack.SetServerInfo(region, hostname); // fleet labels; server-lifetime, survives EndMatch
string matchId = Tombstack.StartMatch(); // server: flips role to "server", mints a match id
Tombstack.EndMatch();
// Server-triggered log pull (write-scoped server token)
Tombstack.RequestPlayerLogs(Tombstack.PullTarget target, targetValue, reason);
Tombstack.RequestPlayerLogs(target, reason); // target = userId, or Tombstack.TARGET_ALL_ON_THIS_SERVER
Tombstack.OnAnomalousDisconnect(userId, reason);AnkleBreaker C# standard (csharp-unity skill): private fields _camelCase, public
methods PascalCase, private methods camelCase, constants UPPER_SNAKE_CASE,
ScriptableObjects {Name}SO. Unity message methods stay PascalCase. The SDK keeps a thin
static facade (Sentry-style) rather than the Manager/HandlerData triad because it ships to
external studios and must not depend on AnkleBreaker's in-game framework. IL2CPP/AOT-safe:
no reflection-based serialization beyond JsonUtility, no dynamic codegen.
- Native crash core (Windows SEH / POSIX signals / Mach) + on-disk minidump upload —
managed exceptions are covered today; native is the next track (
../native/, plan written). - Breadcrumb
categoryas a first-class wire field (currently folded into the message as a[category]prefix — the ingest schema has no category field yet).
Target engine confirmed: Unity (Mithrall, appId 2838890).
{ "dependencies": { "com.anklebreaker.tombstack": "https://github.com/AnkleBreaker-Studio/tombstack-unity.git#v0.12.0" } }