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 }];