Appearance
kids.kapish.tracking
unity
Analytics and telemetry with consent gating and pluggable providers. Separate from logging by design, with an optional one-way bridge for teams that want log echo.
Package Identity
| Field | Value |
|---|---|
| Name | kids.kapish.tracking |
| Display name | Carrot.Tracking |
| Version | 0.1.0 |
| Unity | 6000.0+ |
| License | MIT |
Architecture at a Glance
┌────────────────────────┐
game code ─▶ Tracker (singleton) │
│ ├── LogSink? ───────────▶ ITrackingLogSink (optional)
│ ├── Signals (always) ────▶ EventTracked, PageViewTracked, ConsentChanged
│ ├── Consent gate │
│ │ └─ Pending: queue │
│ │ └─ Denied: drop │
│ │ └─ Granted/less: │
│ └─────────────────────┐ │
└────────────────────────┼─┘
│
▼ fan-out (try/catch per provider)
┌─────────────────┐
│ ITrackingProvider│ (N instances)
└─────────────────┘
│
▼
App Insights, Log Analytics, Debug, …The orchestrator is Tracker, a MonoSingleton<Tracker> living on a scene object. It's the only piece of tracking state the rest of the game touches.
Design: Tracking ≠ Logging
This is the single most important architectural point in the package.
Tracking and logging are separate concerns even when the data looks similar. Tracking is about product analytics: funnels, engagement, feature usage, A/B cohorts. Logging is about diagnostics: what happened, what failed, what took too long. They have different retention policies, different audiences (product vs. engineering), different privacy requirements, and often different backends.
Mirrors the TypeScript side — @carrot/tracking-vue and @carrot/logging-vue are separate packages with no hard coupling, for the same reason.
The seam: ITrackingLogSink
Many teams do want tracking calls mirrored into logs (invaluable during development, useful in production for correlating user actions with diagnostic traces). The package supports this without a hard dependency:
Tracker.LogSinkis anITrackingLogSinkproperty,nullby default.- If you want log echo, implement a small adapter in your app or a glue assembly that references both
Carrot.Trackingand your logger of choice. - Assign it once at bootstrap:
Tracker.Instance.LogSink = new MyLogSinkAdapter();
This keeps Carrot.Tracking.asmdef free of any Carrot.Logging reference. The two packages can evolve independently, be used independently, or be swapped independently.
LogSink fires before consent gating
Log echo happens at the top of every tracking method, unconditionally. That's deliberate:
- Logs are a diagnostic channel under the developer's control, not a telemetry channel subject to user consent.
- You almost always want to see what the game tried to track in your logs, even when consent is denied — otherwise debugging consent logic itself becomes opaque.
- If your logger has its own privacy concerns, scrub at the logger adapter, not at the tracker.
Consent Model
Three states, strict transitions handled by SetConsent:
| From → To | Behaviour |
|---|---|
Pending → Granted | Flush the queue through the provider pipeline. |
Pending → Denied | Clear the queue. |
Granted → Denied | Clear the queue (it should be empty anyway). Subsequent events drop. |
Denied → Granted | Queue is empty, but subsequent events now forward. |
| Same → Same | No-op. No signal dispatched. |
Every real transition fires ConsentChanged.
Why queue while Pending?
Games often start tracking calls before the consent dialog has resolved — boot events, scene load, asset warmup. Dropping these means your funnel is broken. Forcing developers to gate every call manually is brittle. Queueing is the pragmatic middle ground: if the user grants consent within a few seconds, you get the boot events; if they deny, they get dropped cleanly.
The default maxQueueSize of 500 is generous but bounded. When the queue fills, the oldest event is dropped (FIFO) — new events are more valuable than stale boot chatter. If your game generates thousands of events before consent resolves, tune this up or resolve consent earlier.
Consentless mode
consentless = true skips the gate entirely. Use for:
- Platforms where explicit consent isn't required (e.g. enterprise installs, B2B games).
- Debug / development scenes.
- Server-side telemetry where there's no end-user.
Setting consentless does not affect the log sink or signal dispatch — those always fire anyway.
Signals Always Fire
EventTracked, PageViewTracked, and ConsentChanged dispatch regardless of consent state. This is intentional and important.
The reasoning: signals are in-process, local observability. They're how the achievement system, quest system, tutorial watcher, or gameplay analytics debugger react to tracking calls. Those systems aren't shipping data off-device; they're listening to your game telling itself what's happening. Blocking them on consent would be confusing and wrong.
The consent gate applies to providers — i.e. data leaving the process. Signals stay inside.
If you implement an in-game feature that does need to respect consent (e.g. a personalised recommendations module), gate it explicitly against Tracker.Instance.Consent. Don't try to fix it at the signal layer.
Provider Isolation
Every method on every provider call is wrapped in its own try/catch. A third-party SDK that throws on initialization, panics on a bad property value, or dies because its network stack is unhappy will:
- Log an error via
Debug.LogErrorwith the provider name and the operation. - Not take out the other providers.
- Not prevent the queue from flushing.
- Not prevent future calls from attempting.
This matters because analytics SDKs are historically some of the worst-behaved code you'll ship. Isolating them is survival gear.
Event Flow (Event/PageView)
caller: Tracker.Instance.Event("level_completed", props)
│
├─ LogSink?.OnEvent(name, props) // always, if set
├─ EventTracked.Dispatch(new TrackingEvent(..)) // always
│
└─ if (consentless || Consent == Granted)
├─ for each provider: try provider.Event(..) // fan-out, isolated
else if (Consent == Pending)
└─ Enqueue(QueuedEvent) // oldest dropped if full
else // Denied
└─ dropIdentify and Reset follow the same pattern but don't dispatch signals (no one's yet needed to observe them locally — easy to add if that changes).
Queue Semantics
Queue<QueuedEvent>— plain FIFO, no priority.QueuedEventis a readonly struct carrying the kind (PageView/Event/Identify/Reset), name/path/userId, and properties.- Queue is bounded by
maxQueueSize.Enqueuedrops from the front until there's room. FlushQueuedrains everything through the sameDispatch*helpers used on the hot path.
The queue is not persisted across sessions. If the player quits before granting consent, queued events are lost. Persisting them would be a conscious decision (and arguably a consent violation) — keep it in memory only unless you have a specific, documented reason otherwise.
TrackingProperties: Why Dictionary<string, object>?
It's the lowest-common-denominator shape that every analytics SDK accepts, and it maps cleanly to JSON, IDictionary<string, string> (via ToString), and name/value pair APIs. Subclassing Dictionary gives us:
- Full dictionary API for free (
[]indexer,Add,ContainsKey, iteration). - Direct interop with anything that takes
IDictionary<string, object>. - Cheap fluent builder via
With.
The cost is that it's Dictionary<string, object> — no type safety, no schema, no discriminated unions. That's acceptable for a property bag destined for a loose-typed analytics backend. For strongly-typed in-game events, keep those in domain types and only flatten into TrackingProperties at the tracking boundary.
Providers
DebugTrackingProvider
Built-in. Prints every call to the Unity console with a configurable prefix. Use it during development and in your CI smoke tests.
AppInsightsTrackingProvider (stub)
Stub for Azure Application Insights. The source file documents the integration path:
- Install the Microsoft.ApplicationInsights SDK (NuGet for Unity or manual DLL drop).
Initialize→ construct aTelemetryClientwith your instrumentation key.PageView/Event/Identify→ correspondingTrack*methods.- Map
TrackingPropertiestoTelemetryContext.Properties. - Flush on
OnApplicationQuit.
Left as a stub because pulling the App Insights dependency chain into every game that might want telemetry isn't acceptable. Teams that want it wire the real implementation in their own assembly.
LogAnalyticsTrackingProvider (stub)
Stub for Azure Log Analytics Workspace via the HTTP Data Collector API:
- HMAC-SHA256 signed POST to
https://{workspaceId}.ods.opinsights.azure.com/api/logs. - Signed headers:
Authorization,x-ms-date,Log-Type,time-generated-field. - Batch events and flush on a timer or size threshold (
UnityWebRequestorHttpClient). - Each call type maps to its own Log-Type with a distinct schema.
Also left as a stub so we don't force an HTTP client dependency or a workspace key management discussion on every consumer.
Singleton Choice
Tracker is a MonoSingleton<Tracker> (from kids.kapish). The reasoning:
- Tracking is genuinely app-global. Multiple trackers would be a footgun.
- Providers are typically
MonoBehaviours with inspector-configured keys, so a Unity-object singleton pairs naturally. - Singleton provides the
DontDestroyOnLoadlifecycle games expect for telemetry.
If you need to test the orchestrator in isolation, the core logic (consent gate, queue, signals, fan-out) is deliberately kept on one class — you can exercise it with mock providers and just never instantiate a real scene singleton in the test.
Design Decisions
Semantic methods, not a single Emit(string, props) — PageView / Event / Identify / Reset map directly onto what analytics SDKs actually do. A single generic emit would force every provider to parse a string or enum and branch internally, which is both slower and more error-prone.
Consent lives on Tracker, not on individual providers — Consent is a product/legal decision, not a provider implementation detail. Single source of truth, single API to wire consent UI into.
Queue is in-memory, not persisted — Persisting pre-consent events would be a consent violation in spirit if not in letter. If a player quits before deciding, those events are legitimately gone.
Signals fire unconditionally — Local in-process observability is not the same as telemetry egress. Consent gates egress, not observation.
LogSink is a property, not a collection — One adapter is enough; adapters can fan out internally if needed. Keeps the surface minimal.
No async / awaitable dispatch — Tracker calls are fire-and-forget. Providers that need async I/O (HTTP, etc.) batch internally. The tracker itself never blocks the caller.
Built-in Debug provider — Every telemetry system needs a console-output mode during development. Shipping one in the core package saves every consumer from reinventing it.
Stubs for App Insights and Log Analytics — These are the two backends used across Carrot deployments. The stubs document the integration path without dragging the SDKs into the core package.
Adoption
This package slots into any Carrot game that needs analytics. It pairs with kids.kapish.signals (which it depends on) and optionally with kids.kapish.logging (via a user-authored ITrackingLogSink adapter). Game code should talk to Tracker.Instance only — never to providers directly.
File Structure
Runtime/
Carrot.Tracking.asmdef
Tracker.cs # MonoSingleton orchestrator
ITrackingProvider.cs
ITrackingLogSink.cs
ConsentState.cs
TrackingProperties.cs
TrackingEvent.cs # TrackingEvent + TrackingPageView
Providers/
DebugTrackingProvider.cs
AppInsightsTrackingProvider.cs # stub with integration notes
LogAnalyticsTrackingProvider.cs # stub with integration notes
Editor/
Carrot.Tracking.Editor.asmdefUsage Guide
Consent-gated analytics with pluggable providers. Access everything through the Tracker MonoSingleton.
Bootstrap
Put a Tracker component on a persistent bootstrap GameObject (typically alongside your other singletons), then add your providers as sibling components or separate GameObjects. Register them once the scene is alive.
csharp
using Carrot.Tracking;
using Carrot.Tracking.Providers;
using UnityEngine;
public class TrackingBootstrap : MonoBehaviour
{
[SerializeField] private DebugTrackingProvider debugProvider;
[SerializeField] private AppInsightsTrackingProvider appInsightsProvider;
private void Start()
{
var tracker = Tracker.Instance;
tracker.AddProvider(this.debugProvider);
tracker.AddProvider(this.appInsightsProvider);
tracker.InitializeProviders();
// Initial state: Pending — events queue until the consent dialog resolves.
}
}Consent Flow
Wire SetConsent into whatever consent UI your game uses.
csharp
using Carrot.Tracking;
public class ConsentDialogController : MonoBehaviour
{
public void OnAcceptClicked() => Tracker.Instance.SetConsent(ConsentState.Granted);
public void OnDeclineClicked() => Tracker.Instance.SetConsent(ConsentState.Denied);
}Transitions:
Pending → Grantedflushes queued events through the providers.Pending → Deniedclears the queue.- Either transition fires
Tracker.ConsentChanged.
Restoring a persisted decision
Load the stored decision and apply it before any tracking calls fire, so you don't queue events unnecessarily.
csharp
private void Start()
{
if (PlayerPrefs.HasKey("consent"))
{
var stored = (ConsentState) PlayerPrefs.GetInt("consent");
Tracker.Instance.SetConsent(stored);
}
Tracker.Instance.ConsentChanged.Add(state =>
PlayerPrefs.SetInt("consent", (int) state));
}Skipping consent entirely
For platforms or contexts where consent isn't required (B2B, enterprise, debug builds), tick consentless on the Tracker in the inspector — or set it programmatically before adding providers if you prefer. Every event forwards immediately regardless of Consent.
Tracking Events
Simple event
csharp
Tracker.Instance.Event("level_started");Event with properties
csharp
Tracker.Instance.Event("level_completed", TrackingProperties.From("level", 3));Fluent property builder
TrackingProperties.With returns this, so you can chain.
csharp
using Carrot.Tracking;
var props = TrackingProperties
.Create()
.With("level", 3)
.With("difficulty", "hard")
.With("duration_ms", 87_432)
.With("deaths", 2)
.With("perfect_run", false);
Tracker.Instance.Event("level_completed", props);Page views
csharp
Tracker.Instance.PageView("/shop");
Tracker.Instance.PageView("/shop/daily_deal",
TrackingProperties.From("source", "push_notification"));Identify and reset
csharp
Tracker.Instance.Identify("user-7291",
TrackingProperties.Create()
.With("plan", "pro")
.With("cohort", "2026-Q2"));
// On sign-out:
Tracker.Instance.Reset();Listening to Signals (Local Observability)
Signals fire on every tracking call regardless of consent. Perfect for in-game systems that want to react without being a telemetry provider.
csharp
using Carrot.Signals;
using Carrot.Tracking;
using UnityEngine;
public class AchievementsWatcher : MonoBehaviour
{
private Action unsub;
private void OnEnable()
{
this.unsub = Tracker.Instance.EventTracked.Add(this.OnEvent);
}
private void OnDisable() => this.unsub?.Invoke();
private void OnEvent(TrackingEvent evt)
{
if (evt.Name == "enemy_defeated"
&& evt.Properties != null
&& evt.Properties.TryGetValue("type", out var type)
&& (string) type == "boss")
{
this.UnlockAchievement("boss_slayer");
}
}
private void UnlockAchievement(string id) { /* ... */ }
}Consent changes are observable too:
csharp
Tracker.Instance.ConsentChanged.Add(state =>
Debug.Log($"Consent is now {state} (queue was {Tracker.Instance.QueueSize})"));Writing a Custom Provider
Implement ITrackingProvider. If your provider needs inspector configuration (API keys, endpoints), make it a MonoBehaviour. Otherwise a plain class is fine — just instantiate it and call AddProvider.
csharp
namespace MyGame.Telemetry
{
using System.Text;
using Carrot.Tracking;
using UnityEngine;
using UnityEngine.Networking;
public class MyHttpTrackingProvider : MonoBehaviour, ITrackingProvider
{
[SerializeField] private string endpoint;
[SerializeField] private string apiKey;
public string Name => "MyHttp";
public void Initialize()
{
// Validate config, warm up client, start batch flush coroutine, etc.
}
public void PageView(string path, TrackingProperties properties = null) =>
this.Post("pageview", path, properties);
public void Event(string name, TrackingProperties properties = null) =>
this.Post("event", name, properties);
public void Identify(string userId, TrackingProperties traits = null) =>
this.Post("identify", userId, traits);
public void Reset()
{
// Clear cached user context, session id, etc.
}
private void Post(string kind, string name, TrackingProperties properties)
{
// Serialise and POST. Tracker wraps this in try/catch so a throw
// here won't take out other providers — but you should batch and
// flush asynchronously in real code.
}
}
}Register it:
csharp
Tracker.Instance.AddProvider(myHttpProvider);
Tracker.Instance.InitializeProviders();Provider isolation: Tracker wraps every call in try/catch — a throw in your provider logs an error but won't break other providers or the event pipeline. That's the safety net; don't rely on it as a design pattern.
Writing an ITrackingLogSink Adapter
Tracking has no hard dependency on kids.kapish.logging. If you want log echo, write a tiny adapter in your own assembly (or a dedicated glue assembly) that references both.
csharp
namespace MyGame.Tracking
{
using Carrot.Logging; // kids.kapish.logging
using Carrot.Tracking; // kids.kapish.tracking
public class CarrotLoggingTrackingSink : ITrackingLogSink
{
private readonly ILog log;
public CarrotLoggingTrackingSink(ILog log) => this.log = log;
public void OnPageView(string path, TrackingProperties properties) =>
this.log.Info($"[track] PageView: {path} {Format(properties)}");
public void OnEvent(string name, TrackingProperties properties) =>
this.log.Info($"[track] Event: {name} {Format(properties)}");
public void OnIdentify(string userId, TrackingProperties traits) =>
this.log.Info($"[track] Identify: {userId} {Format(traits)}");
public void OnReset() => this.log.Info("[track] Reset");
private static string Format(TrackingProperties p)
{
if (p == null || p.Count == 0) return string.Empty;
return "{ " + string.Join(", ", p) + " }";
}
}
}Assign it at bootstrap:
csharp
Tracker.Instance.LogSink = new CarrotLoggingTrackingSink(myLog);Important: the sink fires before the consent gate. That's by design — developer-facing logs shouldn't be silenced by user consent decisions. If your logger handles PII scrubbing, do it in the logger, not here.
Complete Bootstrap Example
csharp
using Carrot.Tracking;
using Carrot.Tracking.Providers;
using UnityEngine;
public class TrackingBootstrap : MonoBehaviour
{
[SerializeField] private DebugTrackingProvider debugProvider;
[SerializeField] private AppInsightsTrackingProvider appInsightsProvider;
[SerializeField] private bool echoToLogger = true;
private void Awake()
{
var tracker = Tracker.Instance;
// Optional: mirror tracking calls into the logger.
if (this.echoToLogger)
{
tracker.LogSink = new CarrotLoggingTrackingSink(Log.Default);
}
tracker.AddProvider(this.debugProvider);
tracker.AddProvider(this.appInsightsProvider);
tracker.InitializeProviders();
// Restore persisted consent decision.
if (PlayerPrefs.HasKey("consent"))
{
tracker.SetConsent((ConsentState) PlayerPrefs.GetInt("consent"));
}
tracker.ConsentChanged.Add(state =>
PlayerPrefs.SetInt("consent", (int) state));
}
private void Start()
{
// These fire through the LogSink and the signals immediately.
// They hit the providers only if consent is Granted (or consentless).
// If Pending, they queue.
Tracker.Instance.Event("app_started");
Tracker.Instance.PageView("/boot");
}
}Inspecting State
Useful read-only properties on Tracker for HUD overlays, debug panels, or CI assertions:
| Property | Type | Use |
|---|---|---|
Consent | ConsentState | Current consent state. |
Consentless | bool | Whether gating is bypassed. |
QueueSize | int | Number of events currently queued. |
Providers | IReadOnlyList<ITrackingProvider> | Inspect registered providers. |
csharp
Debug.Log($"Consent: {Tracker.Instance.Consent}, queued: {Tracker.Instance.QueueSize}");Gotchas
- Event names should be stable and snake_case-ish. Analytics backends treat event names as keys — renaming breaks your funnels. Pick a convention (
noun_verbe.g.level_completed) and keep it. - Don't put PII in property values unless your backend can handle it and your privacy policy allows it. Scrub at the provider or sink.
- The queue is in-memory only. Events generated while
Pendingare lost if the player quits before deciding — that's intentional (persisting pre-consent events is iffy). - Signals fire even when
Denied. That's local observability, not egress. If you want an in-game feature to also respect consent, gate it explicitly againstTracker.Instance.Consent. - Providers that throw during
Initializeare skipped but stay registered. Subsequent calls still target them and will also be caught. If you want to drop them on failure, callRemoveProviderfrom your own error handling. LogSinkis a single property, not a collection. Fan out inside your adapter if you need to hit multiple logs.