Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

### Features

- Added content-based error event throttling to prevent repeated errors from consuming quota. The new `IErrorEventThrottler` interface and `ContentBasedThrottler` implementation deduplicate `LogError`, `LogException`, and `LogAssertion` events based on message + stacktrace fingerprinting. Configurable via the Editor window ("Enable Error Event Throttling" + "Dedupe Window"). Breadcrumbs and structured logs are not affected. ([#2479](https://github.com/getsentry/sentry-unity/pull/2479))

### Deprecations

- The time-based log debouncing system (`TimeDebounceBase`, `LogTimeDebounce`, `ErrorTimeDebounce`, `WarningTimeDebounce`) and related options (`EnableLogDebouncing`, `DebounceTimeLog`, `DebounceTimeWarning`, `DebounceTimeError`) are now marked as `[Obsolete]`. Use the new content-based event throttling instead. ([#2479](https://github.com/getsentry/sentry-unity/pull/2479))

### Fixes

- Automatically captured transactions and spans now have their `Origin` correctly set. ([#2464](https://github.com/getsentry/sentry-unity/pull/2464))
Expand Down
203 changes: 114 additions & 89 deletions CLAUDE.md

Large diffs are not rendered by default.

35 changes: 34 additions & 1 deletion src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,40 @@ internal static void Display(ScriptableSentryUnityOptions options)
EditorGUILayout.Space();

{
options.EnableErrorEventThrottling = EditorGUILayout.BeginToggleGroup(
new GUIContent("Enable Error Event Throttling",
"Throttles error/exception events based on content to prevent repeated " +
"errors from consuming quota. Does not affect breadcrumbs or structured logs."),
options.EnableErrorEventThrottling);

EditorGUI.indentLevel++;

options.ErrorEventThrottleDedupeWindow = EditorGUILayout.IntField(
new GUIContent("Dedupe Window [ms]",
"Time window for deduplicating repeated errors with the same fingerprint." +
"\nDefault: 1000"),
options.ErrorEventThrottleDedupeWindow);
options.ErrorEventThrottleDedupeWindow = Math.Max(0, options.ErrorEventThrottleDedupeWindow);

EditorGUI.indentLevel--;
EditorGUILayout.EndToggleGroup();
}

EditorGUILayout.Space();
EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray);
EditorGUILayout.Space();

// Deprecated Log Debouncing section
{
EditorGUILayout.LabelField("Deprecated Settings", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Log Debouncing is deprecated. Please use 'Enable Error Event Throttling' above instead. " +
"These settings will be removed in a future version.",
MessageType.Warning);

#pragma warning disable CS0618 // Type or member is obsolete
options.EnableLogDebouncing = EditorGUILayout.BeginToggleGroup(
new GUIContent("Enable Log Debouncing", "The SDK debounces log messages of the " +
new GUIContent("Enable Log Debouncing (Deprecated)", "The SDK debounces log messages of the " +
"same type if they are more frequent than once per second."),
options.EnableLogDebouncing);

Expand All @@ -151,6 +183,7 @@ internal static void Display(ScriptableSentryUnityOptions options)

EditorGUI.indentLevel--;
EditorGUILayout.EndToggleGroup();
#pragma warning restore CS0618 // Type or member is obsolete
}
}
}
162 changes: 162 additions & 0 deletions src/Sentry.Unity/ContentBasedThrottler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Sentry.Unity;

/// <summary>
/// Content-based throttler that deduplicates events based on message and stack trace fingerprint.
/// Only throttles LogType.Error, LogType.Exception, and LogType.Assert events.
/// </summary>
internal class ContentBasedThrottler : IErrorEventThrottler
{
private readonly Dictionary<int, LinkedListNode<LruEntry>> _cache = new();
private readonly LinkedList<LruEntry> _accessOrder = new();
private readonly object _lock = new();
private readonly int _maxBufferSize;
private readonly TimeSpan _dedupeWindow;

private readonly struct LruEntry
{
public readonly int Hash;
public readonly DateTimeOffset Timestamp;

public LruEntry(int hash, DateTimeOffset timestamp)
{
Hash = hash;
Timestamp = timestamp;
}
}

/// <summary>
/// Creates a new content-based throttler.
/// </summary>
/// <param name="dedupeWindow">Time window for deduplicating repeated errors with the same fingerprint.</param>
/// <param name="maxBufferSize">Maximum number of fingerprints to track. Oldest entries are evicted when full.</param>
public ContentBasedThrottler(TimeSpan dedupeWindow, int maxBufferSize = 100)
{
_dedupeWindow = dedupeWindow;
_maxBufferSize = maxBufferSize;
}

/// <inheritdoc />
public bool ShouldCapture(string message, string stackTrace, LogType logType)
{
// Only throttle Error, Exception, and Assert
if (logType is not (LogType.Error or LogType.Exception or LogType.Assert))
{
return true;
}

var hash = ComputeHash(message, stackTrace);
return ShouldCaptureByHash(hash);
}

/// <summary>
/// Checks if an exception should be captured without allocating a fingerprint string.
/// Computes hash directly from exception type name + message + stack trace.
/// </summary>
internal bool ShouldCaptureException(Exception exception)
{
var hash = ComputeExceptionHash(exception);
return ShouldCaptureByHash(hash);
}

private bool ShouldCaptureByHash(int hash)
{
var now = DateTimeOffset.UtcNow;

lock (_lock)
{
if (_cache.TryGetValue(hash, out var existingNode))
{
// Entry exists - check if still within dedupe window
if (now - existingNode.Value.Timestamp < _dedupeWindow)
{
return false; // Throttle - seen recently
}

// Entry expired - update timestamp and move to end (most recently used)
_accessOrder.Remove(existingNode);
var newNode = _accessOrder.AddLast(new LruEntry(hash, now));
_cache[hash] = newNode;
return true; // Allow capture
}

// New entry - evict oldest if buffer is full
if (_cache.Count >= _maxBufferSize)
{
EvictOldest();
}

// Add new entry at end (most recently used)
var node = _accessOrder.AddLast(new LruEntry(hash, now));
_cache[hash] = node;
return true; // Allow capture
}
}

private static int ComputeExceptionHash(Exception exception)
{
// Compute hash from exception type name + ":" + message + stack trace
// without allocating a combined string
var typeName = exception.GetType().Name;
var message = exception.Message;
var stackTrace = exception.StackTrace;

// Hash the type name
var hash = typeName?.GetHashCode() ?? 0;

// Add separator ":"
hash = hash * 31 + ':';

// Add message hash
hash = hash * 31 + (message?.GetHashCode() ?? 0);

// Add stack trace prefix hash
if (!string.IsNullOrEmpty(stackTrace))
{
hash = hash * 31 + ComputeStackTraceHash(stackTrace!, 200);
}

return hash;
}

private static int ComputeHash(string message, string? stackTrace)
{
// Start with message hash
var hash = message?.GetHashCode() ?? 0;

// Combine with stack trace prefix hash using multiplicative combining (not XOR)
// Process character-by-character to avoid Substring allocation
if (!string.IsNullOrEmpty(stackTrace))
{
var stackTraceHash = ComputeStackTraceHash(stackTrace!, 200);
hash = hash * 31 + stackTraceHash;
}

return hash;
}

private static int ComputeStackTraceHash(string stackTrace, int maxLength)
{
var length = Math.Min(stackTrace.Length, maxLength);
var hash = 17;
for (var i = 0; i < length; i++)
{
hash = hash * 31 + stackTrace[i];
}
return hash;
}

private void EvictOldest()
{
// O(1) eviction - remove from head of linked list
var oldest = _accessOrder.First;
if (oldest != null)
{
_cache.Remove(oldest.Value.Hash);
_accessOrder.RemoveFirst();
}
}
}
48 changes: 48 additions & 0 deletions src/Sentry.Unity/IErrorEventThrottler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;
using UnityEngine;

namespace Sentry.Unity;

/// <summary>
/// Interface for throttling error and exception events to prevent quota exhaustion from high-frequency errors.
/// </summary>
/// <remarks>
/// Throttling only affects error/exception event capture - breadcrumbs and structured logs are not affected.
/// </remarks>
public interface IErrorEventThrottler
{
/// <summary>
/// Determines whether an error or exception should be captured as a Sentry event.
/// </summary>
/// <param name="message">The error message or exception fingerprint</param>
/// <param name="stackTrace">Stack trace for fingerprinting</param>
/// <param name="logType">Unity LogType (Error, Exception, or Assert)</param>
/// <returns>True if the event should be captured, false to throttle</returns>
bool ShouldCapture(string message, string stackTrace, LogType logType);
}

/// <summary>
/// Extension methods for <see cref="IErrorEventThrottler"/>.
/// </summary>
internal static class ErrorEventThrottlerExtensions
{
/// <summary>
/// Determines whether an exception should be captured as a Sentry event.
/// Uses an allocation-free path when the throttler is <see cref="ContentBasedThrottler"/>.
/// </summary>
/// <param name="throttler">The throttler instance</param>
/// <param name="exception">The exception to check</param>
/// <returns>True if the event should be captured, false to throttle</returns>
public static bool ShouldCaptureException(this IErrorEventThrottler throttler, Exception exception)
{
// Use allocation-free path for ContentBasedThrottler
if (throttler is ContentBasedThrottler contentBasedThrottler)
{
return contentBasedThrottler.ShouldCaptureException(exception);
}

// Fallback for custom implementations - requires string allocation
var fingerprint = $"{exception.GetType().Name}:{exception.Message}";
return throttler.ShouldCapture(fingerprint, exception.StackTrace ?? string.Empty, LogType.Exception);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ internal class UnityApplicationLoggingIntegration : ISdkIntegration
private readonly IApplication _application;
private readonly ISystemClock _clock;

private ErrorTimeDebounce _errorTimeDebounce = null!; // Set in Register
private LogTimeDebounce _logTimeDebounce = null!; // Set in Register
private WarningTimeDebounce _warningTimeDebounce = null!; // Set in Register

private IHub _hub = null!; // Set in Register
private SentryUnityOptions _options = null!; // Set in Register

Expand All @@ -35,10 +31,6 @@ public void Register(IHub hub, SentryOptions sentryOptions)
_hub = hub ?? throw new ArgumentException("Hub is null.");
_options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'.");

_logTimeDebounce = new LogTimeDebounce(_options.DebounceTimeLog);
_warningTimeDebounce = new WarningTimeDebounce(_options.DebounceTimeWarning);
_errorTimeDebounce = new ErrorTimeDebounce(_options.DebounceTimeError);

_application.LogMessageReceived += OnLogMessageReceived;
_application.Quitting += OnQuitting;
}
Expand All @@ -51,38 +43,22 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo
return;
}

if (IsGettingDebounced(logType))
{
_options.LogDebug("Log message of type '{0}' is getting debounced.", logType);
return;
}

ProcessError(message, stacktrace, logType);
ProcessBreadcrumbs(message, logType);
ProcessStructuredLog(message, logType);
}

private bool IsGettingDebounced(LogType logType)
private void ProcessError(string message, string stacktrace, LogType logType)
{
if (_options.EnableLogDebouncing is false)
if (logType is not LogType.Error || !_options.CaptureLogErrorEvents)
{
return false;
return;
}

return logType switch
{
LogType.Exception => !_errorTimeDebounce.Debounced(),
LogType.Error or LogType.Assert => !_errorTimeDebounce.Debounced(),
LogType.Log => !_logTimeDebounce.Debounced(),
LogType.Warning => !_warningTimeDebounce.Debounced(),
_ => true
};
}

private void ProcessError(string message, string stacktrace, LogType logType)
{
if (logType is not LogType.Error || !_options.CaptureLogErrorEvents)
// Check throttling - only affects event capture, not breadcrumbs or structured logs
if (_options.ErrorEventThrottler is { } throttler && !throttler.ShouldCapture(message, stacktrace, logType))
{
_options.LogDebug("Error event throttled: {0}", message);
return;
}

Expand Down
7 changes: 7 additions & 0 deletions src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ internal void ProcessException(Exception exception, UnityEngine.Object? context)
return;
}

// Check throttling - only affects event capture
if (_options.ErrorEventThrottler is { } throttler && !throttler.ShouldCaptureException(exception))
{
_options.LogDebug("Exception event throttled: {0}", exception.GetType().Name);
return;
}

// TODO: Capture the context (i.e. grab the name if != null and set it as context)

// NOTE: This might not be entirely true, as a user could as well call `Debug.LogException`
Expand Down
7 changes: 3 additions & 4 deletions src/Sentry.Unity/Integrations/UnityWebGLExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ internal sealed class UnityWebGLExceptionHandler : ISdkIntegration
private readonly IApplication _application;
private IHub _hub = null!;
private SentryUnityOptions _options = null!;
private ErrorTimeDebounce _errorTimeDebounce = null!;

internal UnityWebGLExceptionHandler(IApplication? application = null)
{
Expand All @@ -27,7 +26,6 @@ public void Register(IHub hub, SentryOptions sentryOptions)
_options = sentryOptions as SentryUnityOptions
?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'.");

_errorTimeDebounce = new ErrorTimeDebounce(_options.DebounceTimeError);
_application.LogMessageReceived += OnLogMessageReceived;
_application.Quitting += OnQuitting;
}
Expand All @@ -50,9 +48,10 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo
return;
}

if (_options.EnableLogDebouncing && !_errorTimeDebounce.Debounced())
// Check throttling - only affects event capture
if (_options.ErrorEventThrottler is { } throttler && !throttler.ShouldCapture(message, stacktrace, logType))
{
_options.LogDebug("Exception is getting debounced.");
_options.LogDebug("Exception event throttled.");
return;
}

Expand Down
Loading