Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@
text="Tags all outbound events with test: true so the backend can filter them from production analytics." />
</ui:VisualElement>

<ui:VisualElement class="field">
<ui:Toggle name="capture-exceptions" label="CAPTURE EXCEPTIONS" />
<ui:Label class="helper-text below-field"
text="Send uncaught C# exceptions as exception_captured events." />
<ui:VisualElement class="actions last">
<ui:Button name="btn-throw-test-exception" text="Throw Test Exception" />
</ui:VisualElement>
</ui:VisualElement>

<ui:VisualElement name="mobile-attribution-field" class="field">
<ui:Toggle name="enable-mobile-attribution" label="MOBILE ATTRIBUTION" />
<ui:Label class="helper-text below-field"
Expand Down
25 changes: 21 additions & 4 deletions examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ private static readonly (string TabId, string PanelId)[] Tabs =
private TextField _publishableKey, _baseUrl, _flushInterval, _flushSize;
private Toggle _testMode;
private DropdownField _initialConsent;
private Toggle _debug, _enableMobileAttribution;
private Toggle _debug, _enableMobileAttribution, _captureExceptions;
private Button _btnInit, _btnFlush, _btnReset, _btnShutdown, _btnRequestAtt;

// ---- UXML element fields (Consent tab) ----
Expand All @@ -64,7 +64,7 @@ private static readonly (string TabId, string PanelId)[] Tabs =

private VisualElement _typedEventsHost;
private TextField _customEventName, _customEventProps;
private Button _btnCustomEvent;
private Button _btnCustomEvent, _btnThrowTestException;

// ---- UXML element fields (Identity tab) ----

Expand Down Expand Up @@ -184,10 +184,11 @@ private void BindElements()
_debug = Require<Toggle>("debug");
_testMode = Require<Toggle>("test-mode");
_enableMobileAttribution = Require<Toggle>("enable-mobile-attribution");
_captureExceptions = Require<Toggle>("capture-exceptions");
// Inject a tick Label — Unity 2021.3 runtime panels render the
// checked state as a plain coloured square otherwise. USS hides
// the tick when unchecked.
foreach (var toggle in new[] { _debug, _testMode, _enableMobileAttribution })
foreach (var toggle in new[] { _debug, _testMode, _enableMobileAttribution, _captureExceptions })
{
var checkmark = toggle.Q<VisualElement>(className: "unity-toggle__checkmark");
if (checkmark == null) continue;
Expand Down Expand Up @@ -218,6 +219,7 @@ private void BindElements()
_customEventName = Require<TextField>("custom-event-name");
_customEventProps = Require<TextField>("custom-event-props");
_btnCustomEvent = Require<Button>("btn-custom-event");
_btnThrowTestException = Require<Button>("btn-throw-test-exception");

_identityUserId = Require<Label>("identity-user-id");
_identityIdentityType = Require<Label>("identity-identity-type");
Expand Down Expand Up @@ -327,6 +329,7 @@ private void RegisterHandlers()
_initialConsent.RegisterValueChangedCallback(_ => RefreshStatusBar());
_baseUrl.RegisterValueChangedCallback(_ => RefreshStatusBar());
_enableMobileAttribution.RegisterValueChangedCallback(_ => UpdateAttButtonGate());
_captureExceptions.RegisterValueChangedCallback(_ => UpdateThrowTestExceptionButtonGate());

_btnInit.clicked += OnInit;

Expand All @@ -338,6 +341,7 @@ private void RegisterHandlers()
_btnIdentifyTraits.clicked += OnIdentifyTraits;
_btnAlias.clicked += OnAlias;
_btnCustomEvent.clicked += OnSendCustomEvent;
_btnThrowTestException.clicked += OnThrowTestException;

var btnCopyLog = Require<Button>("btn-copy-log");
btnCopyLog.clicked += () =>
Expand Down Expand Up @@ -640,6 +644,7 @@ private void RefreshInitState()
_btnInit.SetEnabled(!_initialised && !string.IsNullOrWhiteSpace(_publishableKey.value));
_btnAlias.SetEnabled(_initialised && IsAliasReady());
UpdateAttButtonGate();
UpdateThrowTestExceptionButtonGate();
}

// ATT prompt is independent of SDK init, but in the demo flow it's
Expand All @@ -653,6 +658,15 @@ private void UpdateAttButtonGate()
_enableMobileAttribution.value);
}

// Throwing before Init, or with the toggle off, is a harmless no-op
// inside the SDK (CaptureException checks _initialized first), but a
// confusing one to test with: the button still reports "Ok" while
// quietly capturing nothing.
private void UpdateThrowTestExceptionButtonGate()
{
_btnThrowTestException.SetEnabled(_initialised && _captureExceptions.value);
}

private void RefreshIdentityPanel()
{
_identityUserId.text = ImmutableAudience.UserId ?? "—";
Expand All @@ -673,17 +687,19 @@ internal readonly struct InitForm
public readonly bool Debug;
public readonly bool TestMode;
public readonly bool EnableMobileAttribution;
public readonly bool CaptureExceptions;
public readonly int? FlushIntervalMs;
public readonly int? FlushSize;

public InitForm(string publishableKey, string baseUrl, ConsentLevel consent, bool debug, bool testMode, bool enableMobileAttribution, int? flushIntervalMs, int? flushSize)
public InitForm(string publishableKey, string baseUrl, ConsentLevel consent, bool debug, bool testMode, bool enableMobileAttribution, bool captureExceptions, int? flushIntervalMs, int? flushSize)
{
PublishableKey = publishableKey;
BaseUrl = baseUrl;
Consent = consent;
Debug = debug;
TestMode = testMode;
EnableMobileAttribution = enableMobileAttribution;
CaptureExceptions = captureExceptions;
FlushIntervalMs = flushIntervalMs;
FlushSize = flushSize;
}
Expand All @@ -701,6 +717,7 @@ internal InitForm CaptureInitForm()
debug: _debug.value,
testMode: _testMode.value,
enableMobileAttribution: _enableMobileAttribution.value,
captureExceptions: _captureExceptions.value,
flushIntervalMs: flushIntervalMs,
flushSize: flushSize);
}
Expand Down
12 changes: 12 additions & 0 deletions examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ private void OnSendCustomEvent() => RunAndLog("track()", () =>
return Json.Serialize(echo, 2);
});

// Logs a fake exception so exception capture (when enabled on the
// Setup tab) has something to pick up. Debug.LogException reaches
// Unity's LogType.Exception channel the same way a real uncaught
// exception would, without actually crashing anything.
private void OnThrowTestException() => RunAndLog("throwTestException()", () =>
{
Debug.LogException(new Exception("Sample app test exception"));
return "Logged a test exception via Debug.LogException.";
});

// ---- SDK action handlers: consent ----

// None clears the anonymous ID and stops future collection; already-queued
Expand Down Expand Up @@ -333,6 +343,7 @@ private AudienceConfig BuildAudienceConfig(InitForm form, Action<AudienceError>
Debug = form.Debug,
TestMode = form.TestMode,
EnableMobileAttribution = form.EnableMobileAttribution,
ErrorTracking = new ErrorTrackingConfig { CaptureExceptions = form.CaptureExceptions },
OnError = onError,
};
if (form.FlushIntervalMs is int flushMs && flushMs > 0)
Expand All @@ -356,6 +367,7 @@ private static Dictionary<string, object> BuildConfigEcho(AudienceConfig config)
["debug"] = config.Debug,
["testMode"] = config.TestMode,
["enableMobileAttribution"] = config.EnableMobileAttribution,
["captureExceptions"] = config.ErrorTracking?.CaptureExceptions ?? false,
["flushIntervalSeconds"] = config.FlushIntervalSeconds,
["flushSize"] = config.FlushSize,
["shutdownFlushTimeoutMs"] = config.ShutdownFlushTimeoutMs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,50 @@ public IEnumerator CustomTrack_WithDictionaryProps_FlushReportsOk()
yield return FlushAndAssertNoErrors();
}

[UnityTest]
public IEnumerator ExceptionCapture_WhenEnabled_QueuesEventAndFlushReportsOk()
{
yield return LoadAndInit(configure: root =>
{
root.Q<Toggle>(SampleAppUi.Setup.CaptureExceptions).value = true;
});

var queueLabel = _root!.Q<Label>(SampleAppUi.StatusBar.Queue);
int.TryParse(queueLabel.text, out var baseline);

// ignoreFailingMessages doesn't cover LogType.Exception; must be declared expected here.
LogAssert.Expect(LogType.Exception, "Exception: Sample app test exception");
_root.Q<Button>(SampleAppUi.Buttons.ThrowTestException).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.ThrowTestException, LogLevels.Ok, 5f);

yield return SampleAppTestHelpers.WaitForCondition(
() => int.TryParse(queueLabel.text, out var current) && current > baseline,
2f, "queue size to grow after throwing a test exception with capture enabled");

yield return FlushAndAssertNoErrors("exception capture enabled");
}

[UnityTest]
public IEnumerator ExceptionCapture_WhenDisabled_DoesNotQueueEvent()
{
// capture-exceptions defaults to off; LoadAndInit leaves it untouched.
yield return LoadAndInit();

var queueLabel = _root!.Q<Label>(SampleAppUi.StatusBar.Queue);
int.TryParse(queueLabel.text, out var baseline);

LogAssert.Expect(LogType.Exception, "Exception: Sample app test exception");
_root.Q<Button>(SampleAppUi.Buttons.ThrowTestException).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.ThrowTestException, LogLevels.Ok, 5f);
yield return null;

int.TryParse(queueLabel.text, out var afterThrow);
Assert.AreEqual(baseline, afterThrow,
"queue size must not grow from a thrown exception when capture is disabled");

yield return FlushAndAssertNoErrors("exception capture disabled");
}

// ---- Lifecycle / control-plane tests ----

[UnityTest]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ internal static class Setup
internal const string TestMode = "test-mode";
internal const string FlushInterval = "flush-interval";
internal const string FlushSize = "flush-size";
internal const string CaptureExceptions = "capture-exceptions";
}

internal static class Buttons
Expand All @@ -56,6 +57,7 @@ internal static class Buttons
internal const string ConsentAnon = "btn-consent-anon";
internal const string ConsentFull = "btn-consent-full";
internal const string CustomEvent = "btn-custom-event";
internal const string ThrowTestException = "btn-throw-test-exception";
internal const string Identify = "btn-identify";
internal const string IdentifyTraits = "btn-identify-traits";
internal const string Alias = "btn-alias";
Expand Down Expand Up @@ -152,6 +154,7 @@ internal static class LogLabels
internal const string Identify = "identify()";
internal const string IdentifyTraits = "identify(traits)";
internal const string Alias = "alias()";
internal const string ThrowTestException = "throwTestException()";

// Label AudienceSample.cs.RouteSdkLogToPane gives every mirrored SDK log line.
internal const string Sdk = "sdk";
Expand Down
6 changes: 6 additions & 0 deletions src/Packages/Audience/Runtime/AudienceConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ public class AudienceConfig
/// </remarks>
public string[]? SKAdNetworkIds { get; set; }

/// <summary>
/// Opts into sending uncaught C# exceptions as events. Null (the
/// default) disables capture entirely.
/// </summary>
public ErrorTrackingConfig? ErrorTracking { get; set; }

/// <summary>
/// Interval between automatic flushes to the backend, in seconds.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ internal static class Constants
internal const int MaxClockSkewFutureHours = 24; // Backend rejects eventTimestamp further ahead than this.
internal const int MaxFieldLength = 256; // Backend schema limit.
internal const int ControlPlaneRequestTimeoutSeconds = 30;
// See ExceptionRateLimiter for how these two are used.
internal const int MaxCapturedExceptions = 20;
internal const int ExceptionCaptureRefillSeconds = 10;

internal const string LibraryName = "com.immutable.audience";
internal const string LibraryVersion = "0.7.3";
Expand Down
95 changes: 95 additions & 0 deletions src/Packages/Audience/Runtime/Core/ExceptionRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#nullable enable

using System;
using System.Collections.Generic;

namespace Immutable.Audience
{
/// <summary>
/// Per-exception-type token bucket. Keeps one repeating exception from
/// flooding the event queue without silencing a different, unrelated one.
/// </summary>
/// <remarks>
/// Each exception type gets its own bucket, starting full. A capture
/// spends one token; the bucket refills by one token per
/// <c>refillInterval</c>, capped at <c>capacity</c>. A type that keeps
/// recurring settles into a steady trickle instead of going silent
/// once, or drowning out a different type sharing one global count.
/// </remarks>
internal sealed class ExceptionRateLimiter
{
private readonly int _capacity;
private readonly TimeSpan _refillInterval;
private readonly Func<DateTime> _now;
private readonly Dictionary<string, Bucket> _buckets = new Dictionary<string, Bucket>();
private readonly object _lock = new object();

internal ExceptionRateLimiter(int capacity, TimeSpan refillInterval, Func<DateTime>? now = null)
{
_capacity = capacity;
_refillInterval = refillInterval;
_now = now ?? (() => DateTime.UtcNow);
}

/// <summary>
/// Attempts to spend one token for <paramref name="exceptionType"/>.
/// </summary>
/// <returns>
/// <c>allowed</c>: true if a token was spent and the caller should proceed.
/// <c>firstTimeLimited</c>: true the moment this type's bucket runs out,
/// so the caller can log a warning once per exhaustion episode rather
/// than once per dropped exception.
/// </returns>
internal (bool allowed, bool firstTimeLimited) TryConsume(string exceptionType)
{
var now = _now();
lock (_lock)
{
if (!_buckets.TryGetValue(exceptionType, out var bucket))
{
bucket = new Bucket { Tokens = _capacity, LastRefill = now };
_buckets[exceptionType] = bucket;
}
else
{
Refill(bucket, now);
}

if (bucket.Tokens < 1)
{
var firstTimeLimited = !bucket.HasWarned;
bucket.HasWarned = true;
return (false, firstTimeLimited);
}

bucket.Tokens -= 1;
bucket.HasWarned = false;
return (true, false);
}
}

private void Refill(Bucket bucket, DateTime now)
{
var elapsed = now - bucket.LastRefill;
if (elapsed <= TimeSpan.Zero) return;

var tokensToAdd = elapsed.Ticks / (double)_refillInterval.Ticks;
if (tokensToAdd <= 0) return;

bucket.Tokens = Math.Min(_capacity, bucket.Tokens + tokensToAdd);
bucket.LastRefill = now;
}

internal void Reset()
{
lock (_lock) { _buckets.Clear(); }
}

private sealed class Bucket
{
internal double Tokens;
internal DateTime LastRefill;
internal bool HasWarned;
}
}
}
11 changes: 11 additions & 0 deletions src/Packages/Audience/Runtime/Core/ExceptionRateLimiter.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions src/Packages/Audience/Runtime/ErrorTrackingConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#nullable enable

namespace Immutable.Audience
{
/// <summary>
/// Exception-capture settings passed to <see cref="ImmutableAudience.Init"/>
/// via <see cref="AudienceConfig.ErrorTracking"/>.
/// </summary>
public class ErrorTrackingConfig
{
/// <summary>
/// Opts into sending uncaught C# exceptions as <c>exception_captured</c>
/// events. Default <c>false</c>.
/// </summary>
/// <remarks>
/// Managed exceptions only. Native crashes (segfaults, ANRs) are not
/// covered. Capture is capped per run so a repeating exception can't
/// flood the event queue.
/// </remarks>
public bool CaptureExceptions { get; set; } = false;
}
}
11 changes: 11 additions & 0 deletions src/Packages/Audience/Runtime/ErrorTrackingConfig.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading