diff --git a/CHANGELOG.md b/CHANGELOG.md
index db6ddf564..8546d7009 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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))
diff --git a/CLAUDE.md b/CLAUDE.md
index b8c6d3182..910fc3276 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,6 +7,7 @@ This file provides comprehensive guidance for AI agents and developers working w
## 1. Overview & Quick Reference
### Repository Purpose
+
The Sentry Unity SDK provides error monitoring, performance tracing, and crash reporting for Unity applications across all platforms (Android, iOS, macOS, Windows, Linux, WebGL, PlayStation, Xbox).
### Quick Commands
@@ -45,18 +46,19 @@ pwsh scripts/repack.ps1
### Key Directories
-| Directory | Purpose |
-|-----------|---------|
-| `src/` | Source code for all platform implementations |
-| `package-dev/` | Development Unity package with all assemblies |
-| `package/` | Release package template for UPM |
-| `test/` | Test suite (unit, integration, platform tests) |
-| `modules/` | Git submodules for native SDKs |
-| `samples/` | Sample Unity projects |
-| `scripts/` | Build automation and testing scripts |
-| `.github/workflows/` | CI/CD workflow definitions |
+| Directory | Purpose |
+| -------------------- | ---------------------------------------------- |
+| `src/` | Source code for all platform implementations |
+| `package-dev/` | Development Unity package with all assemblies |
+| `package/` | Release package template for UPM |
+| `test/` | Test suite (unit, integration, platform tests) |
+| `modules/` | Git submodules for native SDKs |
+| `samples/` | Sample Unity projects |
+| `scripts/` | Build automation and testing scripts |
+| `.github/workflows/` | CI/CD workflow definitions |
### Git Commit Guidelines
+
- Use simple, direct commit messages without prefixes like "chore:" or "feat:"
- Messages start with a capital letter
@@ -80,6 +82,7 @@ src/
Each platform follows a consistent architecture:
1. **Native Bridge** - Platform-specific interface to native SDK
+
- Android: JNI via `AndroidJavaClass`/`AndroidJavaObject`
- iOS/macOS: Objective-C via `DllImport("__Internal")`
- Windows/Linux: P/Invoke via `DllImport("sentry")`
@@ -91,6 +94,7 @@ Each platform follows a consistent architecture:
4. **Configuration** - Platform-specific options and initialization logic
### Assembly Structure
+
- Runtime assemblies separate from Editor assemblies
- Platform-specific assemblies compile only for target platforms
- Clear dependency hierarchy prevents circular references
@@ -104,31 +108,32 @@ Each platform follows a consistent architecture:
The CI system uses modular, reusable workflows in `.github/workflows/`:
-| Workflow | Purpose |
-|----------|---------|
-| `ci.yml` | Main pipeline - triggers on push/PR |
-| `build.yml` | Reusable build workflow |
-| `sdk.yml` | Native SDK builds (Android, Linux, Windows, Cocoa) |
-| `smoke-test-create.yml` | Creates integration test projects |
-| `smoke-test-build-android.yml` | Builds Android test apps |
-| `smoke-test-run-android.yml` | Runs Android tests on emulator |
-| `smoke-test-build-ios.yml` | Builds iOS test apps |
-| `smoke-test-compile-ios.yml` | Compiles iOS Xcode projects |
-| `smoke-test-run-ios.yml` | Runs iOS tests on simulator |
-| `release.yml` | Manual release preparation |
-| `update-deps.yml` | Scheduled dependency updates (daily) |
-| `create-unity-matrix.yml` | Generates test matrix |
+| Workflow | Purpose |
+| ------------------------------ | -------------------------------------------------- |
+| `ci.yml` | Main pipeline - triggers on push/PR |
+| `build.yml` | Reusable build workflow |
+| `sdk.yml` | Native SDK builds (Android, Linux, Windows, Cocoa) |
+| `smoke-test-create.yml` | Creates integration test projects |
+| `smoke-test-build-android.yml` | Builds Android test apps |
+| `smoke-test-run-android.yml` | Runs Android tests on emulator |
+| `smoke-test-build-ios.yml` | Builds iOS test apps |
+| `smoke-test-compile-ios.yml` | Compiles iOS Xcode projects |
+| `smoke-test-run-ios.yml` | Runs iOS tests on simulator |
+| `release.yml` | Manual release preparation |
+| `update-deps.yml` | Scheduled dependency updates (daily) |
+| `create-unity-matrix.yml` | Generates test matrix |
### Unity Version Matrix
-| Version | PR Testing | Main Branch |
-|---------|------------|-------------|
-| 2021.3.x | No | Yes |
-| 2022.3.x | Yes | Yes |
-| 6000.0.x | Yes | Yes |
-| 6000.1.x | No | Yes |
+| Version | PR Testing | Main Branch |
+| -------- | ---------- | ----------- |
+| 2021.3.x | No | Yes |
+| 2022.3.x | Yes | Yes |
+| 6000.0.x | Yes | Yes |
+| 6000.1.x | No | Yes |
Version mapping is defined in `scripts/ci-env.ps1`:
+
- `2021.3` → `2021.3.45f2`
- `2022.3` → `2022.3.70f1`
- `6000.0` → `6000.0.48f1`
@@ -137,6 +142,7 @@ Version mapping is defined in `scripts/ci-env.ps1`:
### Docker-Based Builds
Builds run in Docker containers using `ghcr.io/unityci/editor` images:
+
- Ensures consistent environment across CI runs
- Container setup in `scripts/ci-docker.sh`
- Includes Unity editor, Android SDK, Java, and .NET
@@ -145,16 +151,16 @@ Builds run in Docker containers using `ghcr.io/unityci/editor` images:
Key targets defined in `Directory.Build.targets`:
-| Target | Purpose |
-|--------|---------|
-| `DownloadNativeSDKs` | Downloads prebuilt native SDKs from CI |
-| `BuildAndroidSDK` | Builds Android SDK via Gradle |
-| `BuildLinuxSDK` | Builds Linux SDK via CMake |
-| `BuildWindowsSDK` | Builds Windows SDK via CMake (Crashpad) |
-| `BuildCocoaSDK` | Downloads iOS/macOS SDKs from releases |
-| `UnityEditModeTest` | Runs edit-mode unit tests |
-| `UnityPlayModeTest` | Runs play-mode tests |
-| `UnitySmokeTestStandalonePlayerIL2CPP` | Runs smoke tests |
+| Target | Purpose |
+| -------------------------------------- | --------------------------------------- |
+| `DownloadNativeSDKs` | Downloads prebuilt native SDKs from CI |
+| `BuildAndroidSDK` | Builds Android SDK via Gradle |
+| `BuildLinuxSDK` | Builds Linux SDK via CMake |
+| `BuildWindowsSDK` | Builds Windows SDK via CMake (Crashpad) |
+| `BuildCocoaSDK` | Downloads iOS/macOS SDKs from releases |
+| `UnityEditModeTest` | Runs edit-mode unit tests |
+| `UnityPlayModeTest` | Runs play-mode tests |
+| `UnitySmokeTestStandalonePlayerIL2CPP` | Runs smoke tests |
### Artifact Caching
@@ -165,6 +171,7 @@ Key targets defined in `Directory.Build.targets`:
### CI Flow
**On Pull Request:**
+
1. Create Unity version matrix (2022.3, 6000.0 only)
2. Build SDK in Docker
3. Validate UPM package contents
@@ -174,6 +181,7 @@ Key targets defined in `Directory.Build.targets`:
7. Measure build sizes
**On Main Branch:**
+
- Same as PR but with all Unity versions
- Build native SDKs in parallel
- Extended test coverage
@@ -201,6 +209,7 @@ dotnet msbuild /t:DownloadNativeSDKs src/Sentry.Unity
```
Downloads prebuilt native SDKs from CI artifacts or releases:
+
- Android: JAR/AAR files to `package-dev/Plugins/Android/Sentry~/`
- iOS: XCFramework to `package-dev/Plugins/iOS/`
- macOS: DYLIB to `package-dev/Plugins/macOS/`
@@ -220,12 +229,12 @@ pwsh scripts/build-and-alias.ps1
### Package Structure
-| Directory | Purpose |
-|-----------|---------|
-| `package-dev/` | Development package with source, used for testing |
-| `package/` | Release template with metadata (package.json, LICENSE) |
-| `package-release/` | Final release package (created dynamically) |
-| `package-release.zip` | Distributed UPM package |
+| Directory | Purpose |
+| --------------------- | ------------------------------------------------------ |
+| `package-dev/` | Development package with source, used for testing |
+| `package/` | Release template with metadata (package.json, LICENSE) |
+| `package-release/` | Final release package (created dynamically) |
+| `package-release.zip` | Distributed UPM package |
### Release Workflow
@@ -237,6 +246,7 @@ pwsh scripts/repack.ps1 # Assembly aliasing + packaging + snapshot update
```
Scripts involved:
+
- `scripts/pack.ps1` - Creates the release package
- `scripts/repack.ps1` - Full preparation pipeline
- `scripts/build-and-alias.ps1` - Build with assembly aliasing
@@ -251,14 +261,14 @@ Scripts involved:
### Platform Implementation Matrix
-| Platform | Bridge Type | DllImport | Key Source Files |
-|----------|-------------|-----------|------------------|
-| Android | JNI | N/A | `SentryJava.cs`, `SentryNativeAndroid.cs` |
-| iOS | Objective-C | `__Internal` | `SentryCocoaBridgeProxy.cs`, `SentryNativeCocoa.cs` |
-| macOS | Objective-C | `__Internal` | `SentryCocoaBridgeProxy.cs`, `SentryNativeCocoa.cs` |
-| Windows | P/Invoke | `sentry` | `SentryNativeBridge.cs`, `CFunctions.cs` |
-| Linux | P/Invoke | `sentry` | `SentryNativeBridge.cs`, `CFunctions.cs` |
-| PlayStation | P/Invoke | `sentry` | `SentryNativeBridge.cs`, `CFunctions.cs` |
+| Platform | Bridge Type | DllImport | Key Source Files |
+| ----------- | ----------- | ------------ | --------------------------------------------------- |
+| Android | JNI | N/A | `SentryJava.cs`, `SentryNativeAndroid.cs` |
+| iOS | Objective-C | `__Internal` | `SentryCocoaBridgeProxy.cs`, `SentryNativeCocoa.cs` |
+| macOS | Objective-C | `__Internal` | `SentryCocoaBridgeProxy.cs`, `SentryNativeCocoa.cs` |
+| Windows | P/Invoke | `sentry` | `SentryNativeBridge.cs`, `CFunctions.cs` |
+| Linux | P/Invoke | `sentry` | `SentryNativeBridge.cs`, `CFunctions.cs` |
+| PlayStation | P/Invoke | `sentry` | `SentryNativeBridge.cs`, `CFunctions.cs` |
### Native SDK Submodules
@@ -272,18 +282,21 @@ modules/
### Key Source Files
**Android (`src/Sentry.Unity.Android/`):**
+
- `SentryJava.cs` - JNI wrapper using `AndroidJavaClass`/`AndroidJavaObject`
- `SentryNativeAndroid.cs` - Configuration and initialization
- `AndroidJavaScopeObserver.cs` - Scope synchronization
- `NativeContextWriter.cs` - Context synchronization
**iOS/macOS (`src/Sentry.Unity.iOS/`):**
+
- `SentryCocoaBridgeProxy.cs` - P/Invoke to Objective-C functions
- `SentryNativeCocoa.cs` - Configuration logic
- `NativeScopeObserver.cs` - Scope synchronization
- `SentryNativeBridge.m` - Objective-C bridge implementation
**Windows/Linux (`src/Sentry.Unity.Native/`):**
+
- `SentryNativeBridge.cs` - P/Invoke bindings to `sentry` C library
- `CFunctions.cs` - Low-level C API definitions
- `SentryNative.cs` - Configuration and crash detection
@@ -343,6 +356,7 @@ package-dev/Plugins/
Base class: `src/Sentry.Unity/ScopeObserver.cs`
All platforms implement:
+
- `AddBreadcrumbImpl()` - Add breadcrumbs to native layer
- `SetTagImpl()` / `UnsetTagImpl()` - Manage tags
- `SetUserImpl()` / `UnsetUserImpl()` - Manage user info
@@ -354,6 +368,7 @@ All platforms implement:
Base class: `src/Sentry.Unity/ContextWriter.cs`
Synchronizes during SDK initialization:
+
- App start time, build type
- OS information
- Device info (CPU, memory, simulator status)
@@ -369,6 +384,7 @@ Synchronizes during SDK initialization:
**`src/Sentry.Unity/SentryMonoBehaviour.cs`**
Central lifecycle manager:
+
- Singleton pattern with `DontDestroyOnLoad`
- Handles `OnApplicationPause()`, `OnApplicationFocus()`, `OnApplicationQuit()`
- Coroutine queue for background thread operations
@@ -379,6 +395,7 @@ Central lifecycle manager:
**`src/Sentry.Unity/SentryUnitySdk.cs`**
Orchestrates initialization:
+
- Configures options from `ScriptableSentryUnityOptions`
- Registers integrations
- Sets up platform-specific callbacks
@@ -388,21 +405,22 @@ Orchestrates initialization:
Located in `src/Sentry.Unity/Integrations/`:
-| Integration | File | Purpose |
-|-------------|------|---------|
-| Scene Manager | `SceneManagerIntegration.cs` | Breadcrumbs for scene load/unload/change |
-| Scene Tracing | `SceneManagerTracingIntegration.cs` | Spans for scene loading |
-| Startup Tracing | `StartupTracingIntegration.cs` | Transaction for app startup |
-| Lifecycle | `LifeCycleIntegration.cs` | Session tracking across pause/resume |
-| Log Handler | `UnityLogHandlerIntegration.cs` | Captures `Debug.LogException()` |
-| App Logging | `UnityApplicationLoggingIntegration.cs` | Hooks `Application.LogMessageReceived` |
-| ANR | `AnrIntegration.cs` | Application Not Responding detection |
-| Low Memory | `LowMemoryIntegration.cs` | Memory warning events |
-| Scope | `UnityScopeIntegration.cs` | Populates scope with Unity context |
+| Integration | File | Purpose |
+| --------------- | --------------------------------------- | ---------------------------------------- |
+| Scene Manager | `SceneManagerIntegration.cs` | Breadcrumbs for scene load/unload/change |
+| Scene Tracing | `SceneManagerTracingIntegration.cs` | Spans for scene loading |
+| Startup Tracing | `StartupTracingIntegration.cs` | Transaction for app startup |
+| Lifecycle | `LifeCycleIntegration.cs` | Session tracking across pause/resume |
+| Log Handler | `UnityLogHandlerIntegration.cs` | Captures `Debug.LogException()` |
+| App Logging | `UnityApplicationLoggingIntegration.cs` | Hooks `Application.LogMessageReceived` |
+| ANR | `AnrIntegration.cs` | Application Not Responding detection |
+| Low Memory | `LowMemoryIntegration.cs` | Memory warning events |
+| Scope | `UnityScopeIntegration.cs` | Populates scope with Unity context |
### Startup Tracing Detail
`StartupTracingIntegration.cs` creates spans:
+
- `app.start` - Main transaction
- `runtime.init` - Runtime initialization
- `runtime.init.subsystem` - Subsystem registration
@@ -412,12 +430,12 @@ Located in `src/Sentry.Unity/Integrations/`:
### Event Processors
-| Processor | File | Purpose |
-|-----------|------|---------|
-| Unity | `UnityEventProcessor.cs` | App memory, battery, device context |
-| Screenshot | `ScreenshotEventProcessor.cs` | Captures screen as JPEG attachment |
-| View Hierarchy | `ViewHierarchyEventProcessor.cs` | GameObject hierarchy JSON |
-| IL2CPP | `Il2CppEventProcessor.cs` | Line number support for IL2CPP |
+| Processor | File | Purpose |
+| -------------- | -------------------------------- | ----------------------------------- |
+| Unity | `UnityEventProcessor.cs` | App memory, battery, device context |
+| Screenshot | `ScreenshotEventProcessor.cs` | Captures screen as JPEG attachment |
+| View Hierarchy | `ViewHierarchyEventProcessor.cs` | GameObject hierarchy JSON |
+| IL2CPP | `Il2CppEventProcessor.cs` | Line number support for IL2CPP |
### Screenshot Capture
@@ -441,6 +459,7 @@ Located in `src/Sentry.Unity/Integrations/`:
**`src/Sentry.Unity.Editor/ConfigurationWindow/`**
Accessible via **Tools → Sentry** menu:
+
- `SentryWindow.cs` - Main editor window
- `CoreTab.cs` - DSN and basic setup
- `LoggingTab.cs` - Log capture configuration
@@ -455,6 +474,7 @@ Accessible via **Tools → Sentry** menu:
**`src/Sentry.Unity.Editor/Native/BuildPostProcess.cs`**
Runs after build (Priority 1):
+
- Debug symbol upload via `sentry-cli`
- Crash handler installation (Windows: Crashpad)
- Platform-specific configuration
@@ -465,6 +485,7 @@ Runs after build (Priority 1):
**`src/Sentry.Unity/SentryUnityOptions.cs`** (560+ lines)
Key options:
+
- `Enabled` - Enable/disable SDK
- `CaptureInEditor` - Capture events in editor
- `AutoStartupTraces` - Automatic startup tracing
@@ -480,13 +501,13 @@ Key options:
### Test Types
-| Type | Command | Location |
-|------|---------|----------|
-| Edit Mode | `dotnet msbuild /t:UnityEditModeTest` | `test/Sentry.Unity.Tests/` |
-| Play Mode | `dotnet msbuild /t:UnityPlayModeTest` | `test/Sentry.Unity.Tests/` |
-| Editor Tests | `dotnet msbuild /t:UnityEditModeTest` | `test/Sentry.Unity.Editor.Tests/` |
-| Smoke Tests | `dotnet msbuild /t:UnitySmokeTestStandalonePlayerIL2CPP` | Integration tests |
-| Integration | `integration-test.ps1` | `test/Scripts.Integration.Test/` |
+| Type | Command | Location |
+| ------------ | -------------------------------------------------------- | --------------------------------- |
+| Edit Mode | `dotnet msbuild /t:UnityEditModeTest` | `test/Sentry.Unity.Tests/` |
+| Play Mode | `dotnet msbuild /t:UnityPlayModeTest` | `test/Sentry.Unity.Tests/` |
+| Editor Tests | `dotnet msbuild /t:UnityEditModeTest` | `test/Sentry.Unity.Editor.Tests/` |
+| Smoke Tests | `dotnet msbuild /t:UnitySmokeTestStandalonePlayerIL2CPP` | Integration tests |
+| Integration | `integration-test.ps1` | `test/Scripts.Integration.Test/` |
### Running All Tests
@@ -505,15 +526,15 @@ pwsh scripts/run-tests.ps1 -SkipBuild
Located in `test/Scripts.Integration.Test/`:
-| Script | Purpose |
-|--------|---------|
-| `create-project.ps1` | Creates new Unity test project |
-| `add-sentry.ps1` | Adds Sentry package to project |
-| `configure-sentry.ps1` | Configures Sentry in test project |
-| `build-project.ps1` | Builds for target platform |
-| `run-smoke-test.ps1` | Executes smoke and crash tests |
+| Script | Purpose |
+| ------------------------ | ------------------------------------ |
+| `create-project.ps1` | Creates new Unity test project |
+| `add-sentry.ps1` | Adds Sentry package to project |
+| `configure-sentry.ps1` | Configures Sentry in test project |
+| `build-project.ps1` | Builds for target platform |
+| `run-smoke-test.ps1` | Executes smoke and crash tests |
| `measure-build-size.ps1` | Compares build size with/without SDK |
-| `integration-test.ps1` | Full local integration test |
+| `integration-test.ps1` | Full local integration test |
### Local Integration Testing
@@ -525,9 +546,9 @@ Supported platforms: `macOS`, `Windows`, `Linux`, `Android`, `iOS`, `WebGL`
### Sample Projects
-| Project | Path | Purpose |
-|---------|------|---------|
-| Unity of Bugs | `samples/unity-of-bugs/` | Sample app for testing |
+| Project | Path | Purpose |
+| ---------------- | -------------------------- | ---------------------- |
+| Unity of Bugs | `samples/unity-of-bugs/` | Sample app for testing |
| Integration Test | `samples/IntegrationTest/` | CI integration testing |
---
@@ -537,12 +558,14 @@ Supported platforms: `macOS`, `Windows`, `Linux`, `Android`, `iOS`, `WebGL`
### Development Workflow
**Prerequisites (first-time setup or after clean):**
+
```bash
# Download native SDKs - REQUIRED before building
dotnet msbuild /t:DownloadNativeSDKs src/Sentry.Unity
```
**Development cycle:**
+
1. Make changes to source code in `src/`
2. Run `dotnet build` to build and update `package-dev/`
3. Run `pwsh scripts/run-tests.ps1` to build and run all tests
@@ -562,6 +585,7 @@ dotnet msbuild /t:DownloadNativeSDKs src/Sentry.Unity
### Exception Filters
Located in `src/Sentry.Unity/Integrations/`:
+
- `UnityBadGatewayExceptionFilter.cs` - Filters HTTP 502 errors
- `UnityWebExceptionFilter.cs` - Filters web-related exceptions
- `UnitySocketExceptionFilter.cs` - Filters socket exceptions
@@ -576,6 +600,7 @@ Located in `src/Sentry.Unity/Integrations/`:
### Debug Symbol Upload
Configured through Editor window (Debug Symbols tab):
+
- Uses Sentry CLI (`scripts/download-sentry-cli.ps1`)
- IL2CPP method mapping for accurate stack traces
- Optional source inclusion
diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs
index 1f172de6f..705429195 100644
--- a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs
+++ b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs
@@ -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);
@@ -151,6 +183,7 @@ internal static void Display(ScriptableSentryUnityOptions options)
EditorGUI.indentLevel--;
EditorGUILayout.EndToggleGroup();
+#pragma warning restore CS0618 // Type or member is obsolete
}
}
}
diff --git a/src/Sentry.Unity/ContentBasedThrottler.cs b/src/Sentry.Unity/ContentBasedThrottler.cs
new file mode 100644
index 000000000..3e12a1105
--- /dev/null
+++ b/src/Sentry.Unity/ContentBasedThrottler.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace Sentry.Unity;
+
+///
+/// Content-based throttler that deduplicates events based on message and stack trace fingerprint.
+/// Only throttles LogType.Error, LogType.Exception, and LogType.Assert events.
+///
+internal class ContentBasedThrottler : IErrorEventThrottler
+{
+ private readonly Dictionary> _cache = new();
+ private readonly LinkedList _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;
+ }
+ }
+
+ ///
+ /// Creates a new content-based throttler.
+ ///
+ /// Time window for deduplicating repeated errors with the same fingerprint.
+ /// Maximum number of fingerprints to track. Oldest entries are evicted when full.
+ public ContentBasedThrottler(TimeSpan dedupeWindow, int maxBufferSize = 100)
+ {
+ _dedupeWindow = dedupeWindow;
+ _maxBufferSize = maxBufferSize;
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ /// Checks if an exception should be captured without allocating a fingerprint string.
+ /// Computes hash directly from exception type name + message + stack trace.
+ ///
+ 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();
+ }
+ }
+}
diff --git a/src/Sentry.Unity/IErrorEventThrottler.cs b/src/Sentry.Unity/IErrorEventThrottler.cs
new file mode 100644
index 000000000..db7056e5d
--- /dev/null
+++ b/src/Sentry.Unity/IErrorEventThrottler.cs
@@ -0,0 +1,48 @@
+using System;
+using UnityEngine;
+
+namespace Sentry.Unity;
+
+///
+/// Interface for throttling error and exception events to prevent quota exhaustion from high-frequency errors.
+///
+///
+/// Throttling only affects error/exception event capture - breadcrumbs and structured logs are not affected.
+///
+public interface IErrorEventThrottler
+{
+ ///
+ /// Determines whether an error or exception should be captured as a Sentry event.
+ ///
+ /// The error message or exception fingerprint
+ /// Stack trace for fingerprinting
+ /// Unity LogType (Error, Exception, or Assert)
+ /// True if the event should be captured, false to throttle
+ bool ShouldCapture(string message, string stackTrace, LogType logType);
+}
+
+///
+/// Extension methods for .
+///
+internal static class ErrorEventThrottlerExtensions
+{
+ ///
+ /// Determines whether an exception should be captured as a Sentry event.
+ /// Uses an allocation-free path when the throttler is .
+ ///
+ /// The throttler instance
+ /// The exception to check
+ /// True if the event should be captured, false to throttle
+ 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);
+ }
+}
diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs
index 8b2de57a3..5079bbe53 100644
--- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs
+++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs
@@ -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
@@ -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;
}
@@ -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;
}
diff --git a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs
index 51c2a535e..2f88dbc8f 100644
--- a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs
+++ b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs
@@ -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`
diff --git a/src/Sentry.Unity/Integrations/UnityWebGLExceptionHandler.cs b/src/Sentry.Unity/Integrations/UnityWebGLExceptionHandler.cs
index 398303257..30fe410b4 100644
--- a/src/Sentry.Unity/Integrations/UnityWebGLExceptionHandler.cs
+++ b/src/Sentry.Unity/Integrations/UnityWebGLExceptionHandler.cs
@@ -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)
{
@@ -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;
}
@@ -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;
}
diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs
index a7f7e6012..162759996 100644
--- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs
+++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs
@@ -29,10 +29,25 @@ public static string GetConfigPath(string? notDefaultConfigName = null)
[field: SerializeField] public string? Dsn { get; set; }
[field: SerializeField] public bool CaptureInEditor { get; set; } = true;
- [field: SerializeField] public bool EnableLogDebouncing { get; set; } = false;
- [field: SerializeField] public int DebounceTimeLog { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
- [field: SerializeField] public int DebounceTimeWarning { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
- [field: SerializeField] public int DebounceTimeError { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
+ [field: SerializeField] public bool EnableErrorEventThrottling { get; set; } = false;
+ [field: SerializeField] public int ErrorEventThrottleDedupeWindow { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
+
+ // Deprecated debouncing properties - kept for backwards compatibility
+ [field: SerializeField]
+ [Obsolete("Use EnableErrorEventThrottling instead. This property will be removed in a future version.")]
+ public bool EnableLogDebouncing { get; set; } = false;
+
+ [field: SerializeField]
+ [Obsolete("Use EnableErrorEventThrottling and ErrorEventThrottleDedupeWindow instead. This property will be removed in a future version.")]
+ public int DebounceTimeLog { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
+
+ [field: SerializeField]
+ [Obsolete("Use EnableErrorEventThrottling and ErrorEventThrottleDedupeWindow instead. This property will be removed in a future version.")]
+ public int DebounceTimeWarning { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
+
+ [field: SerializeField]
+ [Obsolete("Use EnableErrorEventThrottling and ErrorEventThrottleDedupeWindow instead. This property will be removed in a future version.")]
+ public int DebounceTimeError { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
[field: SerializeField] public double TracesSampleRate { get; set; } = 0;
[field: SerializeField] public bool AutoStartupTraces { get; set; } = true;
@@ -149,10 +164,6 @@ internal SentryUnityOptions ToSentryUnityOptions(
Enabled = Enabled,
Dsn = Dsn,
CaptureInEditor = CaptureInEditor,
- EnableLogDebouncing = EnableLogDebouncing,
- DebounceTimeLog = TimeSpan.FromMilliseconds(DebounceTimeLog),
- DebounceTimeWarning = TimeSpan.FromMilliseconds(DebounceTimeWarning),
- DebounceTimeError = TimeSpan.FromMilliseconds(DebounceTimeError),
TracesSampleRate = TracesSampleRate,
AutoStartupTraces = AutoStartupTraces,
AutoSceneLoadTraces = AutoSceneLoadTraces,
@@ -241,6 +252,12 @@ internal SentryUnityOptions ToSentryUnityOptions(
OptionsConfiguration.Configure(options);
}
+ // Add throttler if enabled and not already set by OptionsConfiguration
+ if (EnableErrorEventThrottling && options.ErrorEventThrottler is null)
+ {
+ options.ErrorEventThrottler = new ContentBasedThrottler(TimeSpan.FromMilliseconds(ErrorEventThrottleDedupeWindow));
+ }
+
// We need to set up logging here because the configure callback might have changed the debug options.
// Without setting up here we might miss out on logs between option-loading (now) and Init - i.e. native configuration
options.SetupUnityLogging();
diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs
index 2f41524b3..55e76f0cc 100644
--- a/src/Sentry.Unity/SentryUnityOptions.cs
+++ b/src/Sentry.Unity/SentryUnityOptions.cs
@@ -48,25 +48,23 @@ public sealed class SentryUnityOptions : SentryOptions
public bool CaptureInEditor { get; set; } = true;
///
- /// Whether Sentry events should be debounced it too frequent.
+ /// Throttler for error/exception events to prevent quota exhaustion from high-frequency errors.
+ /// Only affects event capture - breadcrumbs and structured logs are not affected.
///
- public bool EnableLogDebouncing { get; set; } = false;
-
- ///
- /// Timespan between sending events of LogType.Log
- ///
- public TimeSpan DebounceTimeLog { get; set; } = TimeSpan.FromSeconds(1);
-
- ///
- /// Timespan between sending events of LogType.Warning
- ///
- public TimeSpan DebounceTimeWarning { get; set; } = TimeSpan.FromSeconds(1);
+ ///
+ /// Set via or enable in the Sentry configuration window.
+ /// When enabled via the configuration window, a is used by default.
+ ///
+ public IErrorEventThrottler? ErrorEventThrottler { get; set; }
///
- /// Timespan between sending events of LogType.Assert, LogType.Exception and LogType.Error
+ /// Configures a throttler for error/exception events.
///
- public TimeSpan DebounceTimeError { get; set; } = TimeSpan.FromSeconds(1);
-
+ /// The throttler implementation to use, or null to disable throttling.
+ public void SetErrorEventThrottler(IErrorEventThrottler? throttler)
+ {
+ ErrorEventThrottler = throttler;
+ }
private CompressionLevelWithAuto _requestBodyCompressionLevel = CompressionLevelWithAuto.Auto;
diff --git a/src/Sentry.Unity/TimeDebounceBase.cs b/src/Sentry.Unity/TimeDebounceBase.cs
index ba6a6f42c..70ba74b63 100644
--- a/src/Sentry.Unity/TimeDebounceBase.cs
+++ b/src/Sentry.Unity/TimeDebounceBase.cs
@@ -2,6 +2,10 @@
namespace Sentry.Unity;
+///
+/// Interface for debouncing Unity log messages.
+///
+[Obsolete("Use IErrorEventThrottler and ContentBasedThrottler instead. This interface will be removed in a future version.")]
public interface IUnityLogMessageDebounce
{
bool Debounced();
@@ -10,6 +14,7 @@ public interface IUnityLogMessageDebounce
///
/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback
///
+[Obsolete("Use ContentBasedThrottler instead. This class will be removed in a future version.")]
internal class TimeDebounceBase : IUnityLogMessageDebounce
{
private static DateTimeOffset Now => DateTimeOffset.UtcNow;
@@ -33,6 +38,7 @@ public bool Debounced()
///
/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback
///
+[Obsolete("Use ContentBasedThrottler instead. This class will be removed in a future version.")]
internal sealed class LogTimeDebounce : TimeDebounceBase
{
public LogTimeDebounce(TimeSpan debounceOffset) => DebounceOffset = debounceOffset;
@@ -41,6 +47,7 @@ internal sealed class LogTimeDebounce : TimeDebounceBase
///
/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback
///
+[Obsolete("Use ContentBasedThrottler instead. This class will be removed in a future version.")]
internal sealed class ErrorTimeDebounce : TimeDebounceBase
{
public ErrorTimeDebounce(TimeSpan debounceOffset) => DebounceOffset = debounceOffset;
@@ -49,6 +56,7 @@ internal sealed class ErrorTimeDebounce : TimeDebounceBase
///
/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback
///
+[Obsolete("Use ContentBasedThrottler instead. This class will be removed in a future version.")]
internal sealed class WarningTimeDebounce : TimeDebounceBase
{
public WarningTimeDebounce(TimeSpan debounceOffset) => DebounceOffset = debounceOffset;
diff --git a/test/Sentry.Unity.Editor.Tests/ScriptableSentryUnityOptionsTests.cs b/test/Sentry.Unity.Editor.Tests/ScriptableSentryUnityOptionsTests.cs
index 37f2e0ee8..75675939f 100644
--- a/test/Sentry.Unity.Editor.Tests/ScriptableSentryUnityOptionsTests.cs
+++ b/test/Sentry.Unity.Editor.Tests/ScriptableSentryUnityOptionsTests.cs
@@ -21,7 +21,8 @@ public void ScriptableSentryUnityOptions_Creation_AllPropertiesPresent()
StringAssert.Contains("Enabled", optionsAsString);
StringAssert.Contains("Dsn", optionsAsString);
StringAssert.Contains("CaptureInEditor", optionsAsString);
- StringAssert.Contains("EnableLogDebouncing", optionsAsString);
+ StringAssert.Contains("EnableErrorEventThrottling", optionsAsString);
+ StringAssert.Contains("ErrorEventThrottleDedupeWindow", optionsAsString);
StringAssert.Contains("TracesSampleRate", optionsAsString);
StringAssert.Contains("AutoSessionTracking", optionsAsString);
StringAssert.Contains("AutoSessionTrackingInterval", optionsAsString);
diff --git a/test/Sentry.Unity.Tests/ContentBasedThrottlerTests.cs b/test/Sentry.Unity.Tests/ContentBasedThrottlerTests.cs
new file mode 100644
index 000000000..8e895165c
--- /dev/null
+++ b/test/Sentry.Unity.Tests/ContentBasedThrottlerTests.cs
@@ -0,0 +1,247 @@
+using System;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace Sentry.Unity.Tests;
+
+[TestFixture]
+public class ContentBasedThrottlerTests
+{
+ [Test]
+ public void ShouldCapture_FirstCall_ReturnsTrue()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+
+ var result = throttler.ShouldCapture("test message", "stacktrace", LogType.Error);
+
+ Assert.IsTrue(result);
+ }
+
+ [Test]
+ public void ShouldCapture_SameMessageWithinWindow_ReturnsFalse()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var message = "test message";
+ var stackTrace = "stacktrace";
+
+ throttler.ShouldCapture(message, stackTrace, LogType.Error);
+ var result = throttler.ShouldCapture(message, stackTrace, LogType.Error);
+
+ Assert.IsFalse(result);
+ }
+
+ [Test]
+ public void ShouldCapture_DifferentMessages_BothReturnTrue()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+
+ var result1 = throttler.ShouldCapture("message 1", "stacktrace", LogType.Error);
+ var result2 = throttler.ShouldCapture("message 2", "stacktrace", LogType.Error);
+
+ Assert.IsTrue(result1);
+ Assert.IsTrue(result2);
+ }
+
+ [Test]
+ public void ShouldCapture_SameMessageDifferentStackTrace_BothReturnTrue()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+
+ var result1 = throttler.ShouldCapture("message", "stacktrace 1", LogType.Error);
+ var result2 = throttler.ShouldCapture("message", "stacktrace 2", LogType.Error);
+
+ Assert.IsTrue(result1);
+ Assert.IsTrue(result2);
+ }
+
+ [Test]
+ public void ShouldCapture_LogTypeLog_AlwaysReturnsTrue()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var message = "test message";
+
+ var result1 = throttler.ShouldCapture(message, "stacktrace", LogType.Log);
+ var result2 = throttler.ShouldCapture(message, "stacktrace", LogType.Log);
+
+ Assert.IsTrue(result1);
+ Assert.IsTrue(result2);
+ }
+
+ [Test]
+ public void ShouldCapture_LogTypeWarning_AlwaysReturnsTrue()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var message = "test message";
+
+ var result1 = throttler.ShouldCapture(message, "stacktrace", LogType.Warning);
+ var result2 = throttler.ShouldCapture(message, "stacktrace", LogType.Warning);
+
+ Assert.IsTrue(result1);
+ Assert.IsTrue(result2);
+ }
+
+ [Test]
+ public void ShouldCapture_LogTypeException_ThrottlesRepeated()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var message = "test message";
+
+ var result1 = throttler.ShouldCapture(message, "stacktrace", LogType.Exception);
+ var result2 = throttler.ShouldCapture(message, "stacktrace", LogType.Exception);
+
+ Assert.IsTrue(result1);
+ Assert.IsFalse(result2);
+ }
+
+ [Test]
+ public void ShouldCapture_BufferFull_EvictsOldest()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10), maxBufferSize: 2);
+
+ // Fill buffer
+ throttler.ShouldCapture("message 1", "stack", LogType.Error);
+ throttler.ShouldCapture("message 2", "stack", LogType.Error);
+
+ // This should evict "message 1"
+ throttler.ShouldCapture("message 3", "stack", LogType.Error);
+
+ // "message 1" should now be allowed again since it was evicted
+ var result = throttler.ShouldCapture("message 1", "stack", LogType.Error);
+
+ Assert.IsTrue(result);
+ }
+
+ [Test]
+ public void ShouldCapture_NullStackTrace_DoesNotThrow()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+
+ Assert.DoesNotThrow(() => throttler.ShouldCapture("message", null!, LogType.Error));
+ }
+
+ [Test]
+ public void ShouldCapture_UpdatingExpiredEntry_DoesNotEvict()
+ {
+ // Use a buffer of 3 to avoid cascading evictions affecting our test
+ var throttler = new ContentBasedThrottler(TimeSpan.FromMilliseconds(50), maxBufferSize: 3);
+
+ // Fill buffer with entries A, B, and C
+ throttler.ShouldCapture("message A", "stack", LogType.Error);
+ throttler.ShouldCapture("message B", "stack", LogType.Error);
+ throttler.ShouldCapture("message C", "stack", LogType.Error);
+
+ // Wait for entries to expire
+ System.Threading.Thread.Sleep(60);
+
+ // Update expired entry A - should NOT evict, just update timestamp
+ // If the bug existed, this would evict B, reducing buffer to 2
+ throttler.ShouldCapture("message A", "stack", LogType.Error);
+
+ // Add new entry D - this should evict B (the oldest after A was refreshed)
+ // Buffer should now contain: A, C, D
+ throttler.ShouldCapture("message D", "stack", LogType.Error);
+
+ // B was evicted so should be allowed again
+ var resultB = throttler.ShouldCapture("message B", "stack", LogType.Error);
+ // A was updated (not evicted), so should be throttled (timestamp was refreshed)
+ var resultA = throttler.ShouldCapture("message A", "stack", LogType.Error);
+
+ Assert.IsTrue(resultB, "Entry B should have been evicted and allowed again");
+ Assert.IsFalse(resultA, "Entry A should still be in buffer and throttled");
+ }
+
+ [Test]
+ public void ShouldCapture_EmptyStackTrace_DoesNotThrow()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+
+ Assert.DoesNotThrow(() => throttler.ShouldCapture("message", string.Empty, LogType.Error));
+ }
+
+ [Test]
+ public void ShouldCapture_LogTypeAssert_ThrottlesRepeated()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var message = "assertion failed";
+
+ var result1 = throttler.ShouldCapture(message, "stacktrace", LogType.Assert);
+ var result2 = throttler.ShouldCapture(message, "stacktrace", LogType.Assert);
+
+ Assert.IsTrue(result1);
+ Assert.IsFalse(result2);
+ }
+
+ [Test]
+ public void ShouldCapture_LruEviction_EvictsLeastRecentlyUsed()
+ {
+ // Buffer size of 3 to avoid cascading evictions affecting assertions
+ var throttler = new ContentBasedThrottler(TimeSpan.FromMilliseconds(50), maxBufferSize: 3);
+
+ // Add A, B, and C - fills buffer
+ // Access order: A, B, C
+ throttler.ShouldCapture("message A", "stack", LogType.Error);
+ throttler.ShouldCapture("message B", "stack", LogType.Error);
+ throttler.ShouldCapture("message C", "stack", LogType.Error);
+
+ // Wait for expiry
+ System.Threading.Thread.Sleep(60);
+
+ // Access A again (makes A most recently used)
+ // Access order after: B, C, A
+ throttler.ShouldCapture("message A", "stack", LogType.Error);
+
+ // Add D - should evict B (least recently used)
+ // Access order after: C, A, D
+ throttler.ShouldCapture("message D", "stack", LogType.Error);
+
+ // B was evicted, should be allowed (re-adds B, evicts C)
+ // Access order after: A, D, B
+ var resultB = throttler.ShouldCapture("message B", "stack", LogType.Error);
+ // A was refreshed and not evicted, should be throttled
+ var resultA = throttler.ShouldCapture("message A", "stack", LogType.Error);
+
+ Assert.IsTrue(resultB, "Entry B should have been evicted (LRU) and allowed again");
+ Assert.IsFalse(resultA, "Entry A should still be in buffer and throttled");
+ }
+
+ [Test]
+ public void ShouldCaptureException_ThrottlesRepeatedExceptions()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var exception = new InvalidOperationException("test error");
+
+ var result1 = throttler.ShouldCaptureException(exception);
+ var result2 = throttler.ShouldCaptureException(exception);
+
+ Assert.IsTrue(result1);
+ Assert.IsFalse(result2);
+ }
+
+ [Test]
+ public void ShouldCaptureException_DifferentExceptionTypes_BothAllowed()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var exception1 = new InvalidOperationException("test error");
+ var exception2 = new ArgumentException("test error");
+
+ var result1 = throttler.ShouldCaptureException(exception1);
+ var result2 = throttler.ShouldCaptureException(exception2);
+
+ Assert.IsTrue(result1);
+ Assert.IsTrue(result2);
+ }
+
+ [Test]
+ public void ShouldCaptureException_SameTypeDifferentMessage_BothAllowed()
+ {
+ var throttler = new ContentBasedThrottler(TimeSpan.FromSeconds(10));
+ var exception1 = new InvalidOperationException("error 1");
+ var exception2 = new InvalidOperationException("error 2");
+
+ var result1 = throttler.ShouldCaptureException(exception1);
+ var result2 = throttler.ShouldCaptureException(exception2);
+
+ Assert.IsTrue(result1);
+ Assert.IsTrue(result2);
+ }
+}
diff --git a/test/Sentry.Unity.Tests/DebouncerTests.cs b/test/Sentry.Unity.Tests/DebouncerTests.cs
index 1602e1845..ffaf68b16 100644
--- a/test/Sentry.Unity.Tests/DebouncerTests.cs
+++ b/test/Sentry.Unity.Tests/DebouncerTests.cs
@@ -9,6 +9,7 @@ namespace Sentry.Unity.Tests;
///
/// Testing debouncer in realtime.
///
+#pragma warning disable CS0618 // Type or member is obsolete
public sealed class DebouncerTests
{
private readonly TimeSpan DefaultOffset = TimeSpan.FromMilliseconds(100);
@@ -55,3 +56,4 @@ private IEnumerator AssertDefaultDebounce(TimeDebounceBase debouncer)
Assert.IsTrue(debouncer.Debounced());
}
}
+#pragma warning restore CS0618 // Type or member is obsolete
diff --git a/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs b/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs
index 5fde20688..a3b1f64ab 100644
--- a/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs
+++ b/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs
@@ -44,7 +44,6 @@ public void ToSentryUnityOptions_ValueMapping_AreEqual(bool isBuilding, bool ena
Enabled = false,
Dsn = "test",
CaptureInEditor = false,
- EnableLogDebouncing = true,
TracesSampleRate = 1.0f,
AutoSessionTracking = false,
AutoSessionTrackingInterval = TimeSpan.FromSeconds(1),
@@ -70,7 +69,6 @@ public void ToSentryUnityOptions_ValueMapping_AreEqual(bool isBuilding, bool ena
scriptableOptions.Enabled = expectedOptions.Enabled;
scriptableOptions.Dsn = expectedOptions.Dsn;
scriptableOptions.CaptureInEditor = expectedOptions.CaptureInEditor;
- scriptableOptions.EnableLogDebouncing = expectedOptions.EnableLogDebouncing;
scriptableOptions.TracesSampleRate = (double)expectedOptions.TracesSampleRate;
scriptableOptions.AutoSessionTracking = expectedOptions.AutoSessionTracking;
scriptableOptions.AutoSessionTrackingInterval = (int)expectedOptions.AutoSessionTrackingInterval.TotalMilliseconds;
@@ -166,7 +164,6 @@ public static void AssertOptions(SentryUnityOptions expected, SentryUnityOptions
Assert.AreEqual(expected.Enabled, actual.Enabled);
Assert.AreEqual(expected.Dsn, actual.Dsn);
Assert.AreEqual(expected.CaptureInEditor, actual.CaptureInEditor);
- Assert.AreEqual(expected.EnableLogDebouncing, actual.EnableLogDebouncing);
Assert.AreEqual(expected.TracesSampleRate, actual.TracesSampleRate);
Assert.AreEqual(expected.AutoSessionTracking, actual.AutoSessionTracking);
Assert.AreEqual(expected.AutoSessionTrackingInterval, actual.AutoSessionTrackingInterval);
diff --git a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs
index 73e05178b..dabf3aefa 100644
--- a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs
+++ b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs
@@ -69,18 +69,34 @@ public void OnLogMessageReceived_LogTypeError_CaptureEvent(bool captureLogErrorE
}
[Test]
- [TestCase(LogType.Log)]
- [TestCase(LogType.Warning)]
- [TestCase(LogType.Error)]
- public void OnLogMessageReceived_LogDebounceEnabled_DebouncesMessage(LogType unityLogType)
+ public void OnLogMessageReceived_ErrorEventThrottlerEnabled_ThrottlesRepeatedError()
{
- _fixture.SentryOptions.EnableLogDebouncing = true;
+ _fixture.SentryOptions.ErrorEventThrottler = new ContentBasedThrottler(System.TimeSpan.FromSeconds(10));
var sut = _fixture.GetSut();
var message = TestContext.CurrentContext.Test.Name;
- sut.OnLogMessageReceived(message, string.Empty, unityLogType);
+ // First call should capture the event
+ sut.OnLogMessageReceived(message, string.Empty, LogType.Error);
+ Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count);
+
+ // Second call with same message should be throttled
+ sut.OnLogMessageReceived(message, string.Empty, LogType.Error);
+ Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); // Still 1, not 2
+ }
+
+ [Test]
+ public void OnLogMessageReceived_ErrorEventThrottlerEnabled_DoesNotThrottleNonErrors()
+ {
+ _fixture.SentryOptions.ErrorEventThrottler = new ContentBasedThrottler(System.TimeSpan.FromSeconds(10));
+ var sut = _fixture.GetSut();
+ var message = TestContext.CurrentContext.Test.Name;
+
+ // Log and Warning types should not be throttled (throttling only applies to Error/Exception)
+ sut.OnLogMessageReceived(message, string.Empty, LogType.Log);
+ sut.OnLogMessageReceived(message, string.Empty, LogType.Log);
- Assert.AreEqual(1, _fixture.Hub.ConfigureScopeCalls.Count);
+ // Both calls should add breadcrumbs (throttling doesn't affect breadcrumbs)
+ Assert.AreEqual(2, _fixture.Hub.ConfigureScopeCalls.Count);
}
private static readonly object[] LogTypesCaptured = [new object[] { LogType.Error, SentryLevel.Error, BreadcrumbLevel.Error }];