Argus

How Argus works

The mental model. Read this once and the rest of the docs slot into place — what a test session is, why the same event doesn't fire 12 times, where the data goes when you hit Stop.

Two lifecycle layers: test sessions and captures

Argus has two stacked concepts that often confuse new users:

  • Test session — a long-running playtest. You start it from Argus Control (or it auto-starts when the editor enters Play mode). It has a label (auto-tagged {ProductName}_{Date} if you don't set one). It ends when you stop it via Argus Control. Multiple captures live inside a test session.
  • Capture — a single 2–5 second window of profiling data. Triggered by Argus.TriggerProfilerEvent(...) from game code, or by clicking ▶ Start Capture in Argus Control. One capture = one row on the dashboard.

On the dashboard, the Test sessions tab shows labels (one per playtest); clicking one opens a detail page with every capture taken during that playtest. The flat Sessions tab is the same data ungrouped — useful when you remember the event but not the playtest.

Event semantics: why your event isn't firing 12 times

The most common surprise: you instrument Argus.TriggerProfilerEvent("Order.Complete") in your CompleteOrder() method, the player completes 12 orders during a playtest, and the dashboard shows 1 row for "Order.Complete". Why?

Argus dedup is first-fire-per-(eventName, uniqueId) per test-session run. The same key seen twice in one run is silently dropped. If you don't pass uniqueId, the key is just eventName — and you get exactly one capture per event kind per playtest.

This is by design. Without it, every playtest would flood the dashboard with N near-identical captures of routine game events. With it, each kind of thing you instrument captures once, cleanly.

To capture each instance, pass uniqueId

// Captures one row PER (event, uniqueId) per playtest:
//   12 tutorial orders + 3 boss orders → 2 dashboard rows
Argus.TriggerProfilerEvent("Order.Complete", 3,
    uniqueId: orderConfig.DisplayName);

uniqueId is a human-readable name of the instance — the same string a developer would say out loud ("Tutorial Order", not "order_42"). See the Unity guide for a per-event-kind cheat sheet.

To capture every fire, pass repeatable: true

// Bypass dedup entirely. Every fire captures, throttled only by
// the 30s repeatableEventCooldownSeconds.
Argus.TriggerProfilerEvent("BossFight.Frame", 3, repeatable: true);

Use sparingly: A/B perf hunting where you genuinely need every fire, regression bisects on a specific hot path. The 30s cooldown is a fail-safe; tune via ArgusConfig.repeatableEventCooldownSeconds.

The dedup table resets on each new run

Stop the test-session run via Argus Control → Disconnect (or just re-Initialize). The (eventName, uniqueId) set clears. Next run starts fresh — every event kind eligible for capture again.

Sampling pipeline: editor vs device

Two sampling paths, transparent to your game code. Both produce the same wire format.

Editor path

Argus.TriggerProfilerEvent in editor invokes a static delegate (EditorEventBridge) set up by ProfilerEventManager. Zero PlayerConnection round-trip. The editor's ProfilerRecorder instances sample the editor process directly. Useful for: instant feedback on Editor-side game logic, but the metrics describe the Editor (which has its own overhead) — not a built player.

Device path

On a player build (Development Build, Player connected to the Editor's Profiler dropdown over WiFi/USB), Argus.TriggerProfilerEvent spins up ArgusDeviceSampler. The sampler reads device-side ProfilerRecorders for the requested duration, builds a DeviceSessionPayload, and ships the whole thing to the editor over PlayerConnection. The editor's ProfilerReceiver turns the payload into a ProfilingSession and queues it for upload — same pipeline as editor captures from this point on.

The Argus skill pack's verify command tells you whether a given capture is editor or device data — useful when the metrics look surprisingly fast (editor) or surprisingly slow (device on a low-end Android).

Upload + retention

Upload batching

Captures don't upload immediately. The sender holds them in memory until either:

  • uploadCooldown seconds elapsed since the last upload (default 2s), OR
  • maxPendingSessions reached (default 5).

This means 5 rapid captures fire in one HTTP request — efficient for the dashboard, kind to free-tier rate limits. The downside: if you Stop Play immediately after a capture, there's a ~2 second gap before it lands on the dashboard.

Local backup

If alwaysSaveLocally is true (the default), every capture also writes a JSON backup to Application.persistentDataPath/ArgusCaptures/. Two files per capture: a verbose Unity-shaped one, and a dense _claude.json the AI skill pack reads. These survive even if upload fails or the dashboard purges via retention.

Retention

On the backend, every project has a retentionDays setting (default 7 days on Free, up to 90 on Pro, 365 on Team — see Plans, API keys, team). A nightly retention worker deletes captures past their retention_expires_at column — computed at insert time as created_at + retentionDays, capped by your plan.

On free Render hosting, the retention worker is disabled by default (cron requires a paid Render plan). Storage grows until you upgrade or manually run the cleanup query — see the dashboard deployment doc for the SQL.

Quotas + plan tiers

Two quota dimensions enforced at the ingest endpoint:

  • Sessions per month — capture count. Free: 1,000 / Pro: 50,000 / Team: 500,000. Hit the limit and ingest returns 402 QUOTA_EXCEEDED:sessions_per_month.
  • Projects per org — Free: 1 / Pro: 5 / Team: 25. Hit the limit and project create returns 402 QUOTA_EXCEEDED:projects.

Quotas reset on the 1st of each calendar month. The dashboard's usage page shows current consumption + days until reset.

Tenancy + security

Every API call carries an org context (cookie auth resolves it from your account; API-key auth resolves it from the project the key belongs to). Postgres Row-Level Security policies on every tenant-scoped table enforce the boundary at the database — a misplaced WHERE clause in app code can't leak cross-tenant data.

API keys are stored as bcrypt hashes (cost 12); the cleartext is shown once at creation and unrecoverable thereafter. Rotation keeps a 24-hour grace window where both old + new keys work, so you can deploy the new key to your Unity build before revoking the old.

Webhooks pipeline

After every capture insert, the backend fires session.created via dispatchEvent. The dispatcher checks webhook_endpoints for subscribers; if none, it's a no-op (zero overhead per capture for projects without webhooks configured). Subscribed endpoints get the same canonical envelope, each signed with that endpoint's own HMAC secret OR shaped into a Slack incoming-webhook payload.

Single retry on non-2xx with a 750ms backoff. Anything beyond is the consumer's problem (or future BullMQ work — see the deployment doc's deferred section). Every attempt is recorded in webhook_deliveries with status, duration, and a capped response body — you can debug a flapping endpoint from the dashboard without server access.

See Webhooks & integrations for the full payload schemas + Slack setup.