Skip to content

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

FieldValue
Namekids.kapish.tracking
Display nameCarrot.Tracking
Version0.1.0
Unity6000.0+
LicenseMIT

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.LogSink is an ITrackingLogSink property, null by default.
  • If you want log echo, implement a small adapter in your app or a glue assembly that references both Carrot.Tracking and 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.

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.

Three states, strict transitions handled by SetConsent:

From → ToBehaviour
Pending → GrantedFlush the queue through the provider pipeline.
Pending → DeniedClear the queue.
Granted → DeniedClear the queue (it should be empty anyway). Subsequent events drop.
Denied → GrantedQueue is empty, but subsequent events now forward.
Same → SameNo-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.LogError with 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
       └─ drop

Identify 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.
  • QueuedEvent is a readonly struct carrying the kind (PageView/Event/Identify/Reset), name/path/userId, and properties.
  • Queue is bounded by maxQueueSize. Enqueue drops from the front until there's room.
  • FlushQueue drains everything through the same Dispatch* 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 a TelemetryClient with your instrumentation key.
  • PageView / Event / Identify → corresponding Track* methods.
  • Map TrackingProperties to TelemetryContext.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 (UnityWebRequest or HttpClient).
  • 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 DontDestroyOnLoad lifecycle 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.asmdef

Usage 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.
    }
}

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 → Granted flushes queued events through the providers.
  • Pending → Denied clears 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));
}

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:

PropertyTypeUse
ConsentConsentStateCurrent consent state.
ConsentlessboolWhether gating is bypassed.
QueueSizeintNumber of events currently queued.
ProvidersIReadOnlyList<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_verb e.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 Pending are 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 against Tracker.Instance.Consent.
  • Providers that throw during Initialize are skipped but stay registered. Subsequent calls still target them and will also be caught. If you want to drop them on failure, call RemoveProvider from your own error handling.
  • LogSink is a single property, not a collection. Fan out inside your adapter if you need to hit multiple logs.

Carrot