diff --git a/.editorconfig b/.editorconfig index 422c04f5bc..5a4ca8d924 100644 --- a/.editorconfig +++ b/.editorconfig @@ -208,13 +208,3 @@ indent_style = unset insert_final_newline = false tab_width = unset trim_trailing_whitespace = false - -# Disable AOT analyser for test files -[test/**/*.cs] -dotnet_diagnostic.IL2026.severity = none -dotnet_diagnostic.IL2070.severity = none -dotnet_diagnostic.IL2075.severity = none -dotnet_diagnostic.IL2090.severity = none - -# This appears to be broken and results in false positives (causing dotnet format to delete valid test scenarios) -dotnet_diagnostic.xUnit1025.severity = none diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 7efa71bcc3..d6fd58d14f 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -17,9 +17,6 @@ - - - @@ -37,10 +34,11 @@ - + + - + @@ -71,6 +69,7 @@ + @@ -81,6 +80,10 @@ + + + + @@ -143,6 +146,7 @@ + @@ -171,6 +175,9 @@ + + + diff --git a/.gitattributes b/.gitattributes index b5b1948c75..4f6b1f7048 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.sln text=auto eol=crlf *.slnx text=auto eol=lf *.sh eol=lf +.githooks/* eol=lf *.ps1 eol=lf CHANGELOG.md merge=union diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000000..3115fb1c1e --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +echo "🔍 Checking code formatting..." + +if ! git diff --quiet; then + echo "⚠️ Skipping format check: unstaged changes present." + echo " Stage or stash all changes before committing to enable the format check." + exit 0 +fi + +INCLUDE_ARGS=() +while IFS= read -r f; do + INCLUDE_ARGS+=(--include "$f") +done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.cs$' || true) + +if [ ${#INCLUDE_ARGS[@]} -eq 0 ]; then + echo "✅ No C# files staged." + exit 0 +fi + +FORMAT_OUTPUT=$(dotnet format Sentry.slnx --no-restore \ + "${INCLUDE_ARGS[@]}" \ + --exclude ./modules "./**/*OptionsSetup.cs" ./test/Sentry.Tests/AttributeReaderTests.cs 2>&1) || { + echo "" + echo "❌ dotnet format failed:" + echo "$FORMAT_OUTPUT" + echo "" + exit 1 +} + +if ! git diff --quiet; then + echo "" + echo "❌ Code formatting issues found!" + echo "" + echo "Please stage the formatting fixes and commit again:" + echo "" + echo " git add -u" + echo " git commit" + echo "" + exit 1 +fi + +echo "✅ Code formatting looks good!" +exit 0 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d8149e1ba7..2dd7634e63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @jamescrosswell @Flash0ver +* @jamescrosswell diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index 930d8fa3ea..b1f081f3b9 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -38,22 +38,17 @@ runs: sudo apt-get update sudo apt-get install -y gcc-arm-linux-gnueabihf libc6:armhf - # zstd is needed for cross OS actions/cache but missing from windows-11-arm - # https://github.com/actions/partner-runner-images/issues/99 - - name: Install zstd on Windows ARM64 - uses: ./.github/actions/install-zstd - # See https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md#xcode # Also https://github.com/dotnet/macios/issues/21762#issuecomment-3424033859 (don't reference symlinks) - name: Pin the Xcode Version if: runner.os == 'macOS' shell: bash - run: sudo xcode-select --switch /Applications/Xcode_26.5.app + run: sudo xcode-select --switch /Applications/Xcode_26.6.app # Java 21 is needed by .NET Android - name: Install Java 21 if: ${{ !matrix.container }} - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: '21' @@ -84,7 +79,7 @@ runs: sudo chmod -R a+rw /usr/share/dotnet - name: Install .NET SDK - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json dotnet-version: | @@ -94,7 +89,7 @@ runs: # .NET 5.0 does not support ARM64 on macOS - name: Install .NET 5.0 SDK if: ${{ runner.os != 'macOS' || runner.arch != 'ARM64' }} - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 5.0.x diff --git a/.github/actions/install-zstd/action.yml b/.github/actions/install-zstd/action.yml deleted file mode 100644 index fa103b0ec5..0000000000 --- a/.github/actions/install-zstd/action.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Install zstd on Windows ARM64 -description: | - zstd is needed for cross OS actions/cache but missing from windows-11-arm - (https://github.com/actions/partner-runner-images/issues/99). Once the issue - is resolved, this action can be removed. -inputs: - version: - description: 'zstd version' - required: false - default: '1.5.7' - checksum: - description: 'zstd checksum' - required: false - default: 'acb4e8111511749dc7a3ebedca9b04190e37a17afeb73f55d4425dbf0b90fad9' - -runs: - using: composite - steps: - - name: Install zstd - if: runner.os == 'Windows' && runner.arch == 'ARM64' - shell: pwsh - env: - ZSTD_VERSION: ${{ inputs.version }} - ZSTD_CHECKSUM: ${{ inputs.checksum }} - run: | - $url = "https://github.com/facebook/zstd/releases/download/v$env:ZSTD_VERSION/zstd-v$env:ZSTD_VERSION-win64.zip" - $installDir = "$env:RUNNER_TOOL_CACHE\zstd-v$env:ZSTD_VERSION-win64" - Invoke-WebRequest -OutFile "$env:TEMP\zstd.zip" -Uri $url - $checksum = (Get-FileHash "$env:TEMP\zstd.zip" -Algorithm SHA256).Hash.ToLower() - if ($checksum -ne $env:ZSTD_CHECKSUM) { - Write-Error "zstd checksum verification failed. Expected: $env:ZSTD_CHECKSUM, Actual: $checksum" - exit 1 - } - Expand-Archive -Path "$env:TEMP\zstd.zip" -DestinationPath $env:RUNNER_TOOL_CACHE -Force - echo "$installDir" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - & "$installDir\zstd.exe" --version diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f8d197d99e..13f0914256 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,11 +52,6 @@ jobs: - run: git submodule update --init modules/sentry-native - # zstd is needed for cross OS actions/cache but missing from windows-11-arm - # https://github.com/actions/partner-runner-images/issues/99 - - name: Install zstd on Windows ARM64 - uses: ./.github/actions/install-zstd - - id: cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f0c6c4be28..6b25744b86 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/actions/environment - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: csharp @@ -49,6 +49,6 @@ jobs: run: dotnet build Sentry-CI-CodeQL.slnf --no-restore --nologo - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: category: '/language:csharp' diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index cc4a46d1fa..5a5fe3c5c0 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -162,7 +162,7 @@ jobs: uses: ./.github/actions/environment - name: Select Java 21 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/playwright-blazor-wasm.yml b/.github/workflows/playwright-blazor-wasm.yml index 83f1aa9f2e..d3f36299f4 100644 --- a/.github/workflows/playwright-blazor-wasm.yml +++ b/.github/workflows/playwright-blazor-wasm.yml @@ -41,7 +41,7 @@ jobs: submodules: recursive - name: Install .NET SDK - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 447401dc96..6ee024695d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release ${{ github.event.inputs.version }} - uses: getsentry/craft@bc2e6a9952e62250e5469d5a853a7a438692ccc1 # 2.26.5 + uses: getsentry/craft@cdb657d4bbc70cd497876ad158984b4d345a48ae # 2.26.14 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd9b51124..3421a917ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 6.8.0 + +### Features ✨ + +#### Logs + +- feat(logs): add `log4net` integration by @Flash0ver in [#5172](https://github.com/getsentry/sentry-dotnet/pull/5172) +- feat(logs): add `NLog` integration by @Flash0ver in [#5176](https://github.com/getsentry/sentry-dotnet/pull/5176) + +#### Other + +- feat(serilog): support restrictedToMinimumLevel when configuring Serilog in code by @jamescrosswell in [#5181](https://github.com/getsentry/sentry-dotnet/pull/5181) +- Attachments can now be sent with transactions by setting `AddToTransactions` on `SentryAttachment` [#5182]() by @jamescrosswell in [#5182](https://github.com/getsentry/sentry-dotnet/pull/5182) +- Added `SentrySdk.RecordTransaction` to record already-completed transactions and spans (e.g. replayed through a proxy) [#5333](https://github.com/getsentry/sentry-dotnet/pull/5333) by @jamescrosswell in [#5333](https://github.com/getsentry/sentry-dotnet/pull/5333) +- The `Environment` set on the `Scope` now gets synchronized to the native layers (`sentry-cocoa` and `sentry-native`) by @bitsandfoxes in [#5365](https://github.com/getsentry/sentry-dotnet/pull/5365) + +### Fixes 🐛 + +- fix: `SentrySpanProcessor` no longer leaks spans whose Activity never ends (e.g. aborted requests); the Activity is now held via a `WeakReference` so orphaned spans are pruned once it is garbage-collected. by @Ermabo in [#5393](https://github.com/getsentry/sentry-dotnet/pull/5393) +- The SDK was incorrectly ignoring server rate limits for errors, check-ins, and logs by @jamescrosswell in [#5412](https://github.com/getsentry/sentry-dotnet/pull/5412) +- fix: BackpressureMonitor.Dispose() no longer deadlocks on single-threaded targets by @jamescrosswell in [#5330](https://github.com/getsentry/sentry-dotnet/pull/5330) +- fix: Create a single TaskBlockingListener per process for CaptureBlockingCalls by @jamescrosswell in [#5381](https://github.com/getsentry/sentry-dotnet/pull/5381) + +### Dependencies ⬆️ + +#### Deps + +- chore(deps): update CLI to v3.6.1 by @github-actions in [#5417](https://github.com/getsentry/sentry-dotnet/pull/5417) +- chore(deps): update Native SDK to v0.15.4 by @github-actions in [#5416](https://github.com/getsentry/sentry-dotnet/pull/5416) +- chore(deps): update Cocoa SDK to v9.22.0 by @github-actions in [#5395](https://github.com/getsentry/sentry-dotnet/pull/5395) +- chore(deps): update Java SDK to v8.49.0 by @github-actions in [#5398](https://github.com/getsentry/sentry-dotnet/pull/5398) + +### Other + +- ci: drop install-zstd workaround by @jpnurmi in [#5391](https://github.com/getsentry/sentry-dotnet/pull/5391) +- docs: add Sentry.OpenTelemetry.Exporter to README.md by @Flash0ver in [#5194](https://github.com/getsentry/sentry-dotnet/pull/5194) +- meta: Update CODEOWNERS by @Flash0ver in [#5386](https://github.com/getsentry/sentry-dotnet/pull/5386) + ## 6.7.0 ### Features ✨ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca959b2a07..fc60035091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,31 @@ These solution filters get generated automatically by `/scripts/generate-solutio Also note that script generates a `.generated.NoMobile.slnx` solution, which is an identical copy of `Sentry.slnx`. Again, we don't recommend opening this directly. It exists as a round about way to conditionally set build properties based on the solution name in certain solution filters. You should instead use those solution filters (e.g. `SentryNoMobile.slnf`) when working in the Sentry codebase. +## Git Hooks (Optional but Recommended) + +To automatically check and fix code formatting before committing, you can set up a pre-commit hook: + +```bash +./dev.cs setup-hooks +``` + +Before each commit, the hook runs `dotnet format` against your staged `.cs` files and auto-fixes any formatting issues. If fixes were applied, the commit is blocked — just stage the fixes and try again: + +```bash +git add -u +git commit +``` + +Note: the hook skips automatically if you have unstaged changes, to avoid touching work in progress. + +To opt out at any time: + +```bash +./dev.cs remove-hooks +``` + +**Note:** You can also bypass the hook for a specific commit using `git commit --no-verify` if needed. + ## API changes approval process This repository uses [Verify](https://github.com/VerifyTests/Verify) to store the public API diffs in snapshot files. When a change involves modifying the public API area (by for example adding a public method), that change will need to be approved, otherwise the CI process will fail. diff --git a/Directory.Build.props b/Directory.Build.props index 4fd27add0c..5f9d269dd9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 6.7.0 + 6.8.0 14.0 true true @@ -110,7 +110,7 @@ - 3.6.0 + 3.6.1 $(MSBuildThisFileDirectory)tools\sentry-cli\$(SentryCLIVersion)\ diff --git a/README.md b/README.md index 0650bbb9fc..2cd171e039 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Sentry SDK for .NET | **Sentry.Maui** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Maui.svg)](https://www.nuget.org/packages/Sentry.Maui) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Maui.svg)](https://www.nuget.org/packages/Sentry.Maui) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Maui.svg)](https://www.nuget.org/packages/Sentry.Maui) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/guides/maui) | | **Sentry.NLog** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.NLog.svg)](https://www.nuget.org/packages/Sentry.NLog) | [![NuGet](https://img.shields.io/nuget/v/Sentry.NLog.svg)](https://www.nuget.org/packages/Sentry.NLog) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.NLog.svg)](https://www.nuget.org/packages/Sentry.NLog) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/guides/nlog) | | **Sentry.OpenTelemetry** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/performance/instrumentation/opentelemetry/) | +| **Sentry.OpenTelemetry.Exporter** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry-otlp/) | | **Sentry.Profiling** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/profiling/) | | **Sentry.Serilog** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Serilog.svg)](https://www.nuget.org/packages/Serilog) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/guides/serilog) | diff --git a/Sentry.slnx b/Sentry.slnx index 7efa71bcc3..d6fd58d14f 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -17,9 +17,6 @@ - - - @@ -37,10 +34,11 @@ - + + - + @@ -71,6 +69,7 @@ + @@ -81,6 +80,10 @@ + + + + @@ -143,6 +146,7 @@ + @@ -171,6 +175,9 @@ + + + diff --git a/dev.cs b/dev.cs index 7e441ab646..bb05c297d9 100755 --- a/dev.cs +++ b/dev.cs @@ -103,6 +103,25 @@ public Task AiUpdateAsync(GlobalOptions options = default!) return RunStepAsync("npx @sentry/dotagents install", "npx", "@sentry/dotagents install", options.DryRun); } + [Command("setup-hooks", Description = "Configure git to use the repo's pre-commit hooks from .githooks/.")] + public Task SetupHooksAsync(GlobalOptions options = default!) + { + Console.WriteLine("[dev] Configuring git hooks path to .githooks/"); + return RunStepAsync("git config core.hooksPath", "git", "config core.hooksPath .githooks", options.DryRun); + } + + [Command("remove-hooks", Description = "Restore default git hooks behaviour (stops using .githooks/).")] + public async Task RemoveHooksAsync(GlobalOptions options = default!) + { + Console.WriteLine("[dev] Restoring default git hooks path"); + var exitCode = await RunStepAsync("git config --unset core.hooksPath", "git", "config --unset core.hooksPath", options.DryRun); + + // git exits with code 5 when the key doesn't exist. That's already the + // desired end state, so treat it as success to keep remove-hooks idempotent + // (e.g. running it twice, or before setup-hooks). + return exitCode == 5 ? 0 : exitCode; + } + [Command("nrest", Description = "Restore the default CI solution.")] public Task SolutionRestoreAsync( [Argument("solution", Description = "Solution file to restore. Defaults to platform-specific CI solution if omitted.")] string? solution = null, diff --git a/global.json b/global.json index efdc469cd0..fccbd04e24 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.301", - "workloadVersion": "10.0.301", + "version": "10.0.302", + "workloadVersion": "10.0.302", "rollForward": "disable", "allowPrerelease": false } diff --git a/modules/sentry-cocoa b/modules/sentry-cocoa index 53eb9bd5da..67310f115f 160000 --- a/modules/sentry-cocoa +++ b/modules/sentry-cocoa @@ -1 +1 @@ -Subproject commit 53eb9bd5da18e208cfd80e86863d3f4c7ba21b1d +Subproject commit 67310f115f3fc1e5e1f2e262f9009c64bb387e3f diff --git a/modules/sentry-native b/modules/sentry-native index 0f7802ffa3..a1827544e2 160000 --- a/modules/sentry-native +++ b/modules/sentry-native @@ -1 +1 @@ -Subproject commit 0f7802ffa38be970b9a18dda84b80f6cea388915 +Subproject commit a1827544e2da7e50517615003288c25380f8d457 diff --git a/samples/Sentry.Samples.Log4Net/Program.cs b/samples/Sentry.Samples.Log4Net/Program.cs index c0f33f246f..f35396bf34 100644 --- a/samples/Sentry.Samples.Log4Net/Program.cs +++ b/samples/Sentry.Samples.Log4Net/Program.cs @@ -11,7 +11,7 @@ internal class Program private static void Main() { // Set the user running the process the current principal - // Appender was configure to send the user with the event + // Appender was configured to send the user with the event AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal); // The following anonymous object gets serialized and sent with log messages diff --git a/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj b/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj index 7f0c0b8178..9fe2f12c34 100644 --- a/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj +++ b/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net481 3.5.234 diff --git a/samples/Sentry.Samples.NLog/NLog.config b/samples/Sentry.Samples.NLog/NLog.config index 5022aa23bb..8a5d1bae52 100644 --- a/samples/Sentry.Samples.NLog/NLog.config +++ b/samples/Sentry.Samples.NLog/NLog.config @@ -22,7 +22,8 @@ ignoreEventsWithNoException="False" includeEventDataOnBreadcrumbs="False" includeEventPropertiesAsTags="True" - minimumEventLevel="Error"> + minimumEventLevel="Error" + enableLogs="True"> _monitor; + internal TaskBlockingListener? Listener => _listener; + /// /// Initializes a new instance of the class. /// @@ -48,6 +52,7 @@ internal static readonly SdkVersion NameAndVersion /// Custom Event Exception Processors /// Custom Event Processors /// Custom Transaction Processors + /// The service provider, used to resolve dependencies. /// /// next /// or @@ -60,7 +65,8 @@ public SentryMiddleware( ILogger logger, IEnumerable eventExceptionProcessors, IEnumerable eventProcessors, - IEnumerable transactionProcessors) + IEnumerable transactionProcessors, + IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(getHub); @@ -74,9 +80,9 @@ public SentryMiddleware( if (_options.CaptureBlockingCalls) { - _monitor = new BlockingMonitor(_getHub, _options); - _detectBlockingSyncCtx = new DetectBlockingSynchronizationContext(_monitor); - _listener = new TaskBlockingListener(_monitor); + // Resolve shared singletons to keep overhead constant - See #5378. + _monitor = serviceProvider.GetRequiredService(); + _listener = serviceProvider.GetRequiredService(); } } @@ -150,7 +156,11 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) if (_options.CaptureBlockingCalls && _monitor is not null) { var syncCtx = SynchronizationContext.Current; - SynchronizationContext.SetSynchronizationContext(syncCtx == null ? _detectBlockingSyncCtx : new DetectBlockingSynchronizationContext(_monitor, syncCtx)); + // Created per request as it carries per-request suppression state. + var detectingSyncCtx = syncCtx is null + ? new DetectBlockingSynchronizationContext(_monitor) + : new DetectBlockingSynchronizationContext(_monitor, syncCtx); + SynchronizationContext.SetSynchronizationContext(detectingSyncCtx); try { // For detection to work we need ConfigureAwait=true diff --git a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs index 3b021d6c65..63545d07f9 100644 --- a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Configuration; using Microsoft.Extensions.Options; using Sentry.AspNetCore; +using Sentry.Ben.BlockingDetector; // ReSharper disable once CheckNamespace namespace Microsoft.AspNetCore.Hosting; @@ -112,6 +113,9 @@ public static IWebHostBuilder UseSentry( _ = builder.ConfigureServices(c => _ = c.AddTransient() .AddTransient() + // Single listener/monitor per process (the listener is a global EventListener) - See #5378. + .AddSingleton() + .AddSingleton() .AddTransient() ); diff --git a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj index 01c450f37c..b069d2798d 100644 --- a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj +++ b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj @@ -1,7 +1,7 @@ $(LatestAndroidTfm);$(PreviousAndroidTfm) - 8.48.0 + 8.49.0 $(BaseIntermediateOutputPath)sdks\$(TargetFramework)\Sentry\Android\$(SentryAndroidSdkVersion)\ diff --git a/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs b/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs index 89a9a77187..8b0245cfbd 100644 --- a/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs +++ b/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs @@ -2190,10 +2190,6 @@ interface SentryExperimentalOptions [Export("enableWatchdogTerminationsV2")] bool EnableWatchdogTerminationsV2 { get; set; } - // @property (nonatomic) BOOL enableReplayNetworkDetailsCapturing; - [Export("enableReplayNetworkDetailsCapturing")] - bool EnableReplayNetworkDetailsCapturing { get; set; } - // @property (nonatomic) BOOL enableStandaloneAppStartTracing; [Export("enableStandaloneAppStartTracing")] bool EnableStandaloneAppStartTracing { get; set; } diff --git a/src/Sentry.Log4Net/LevelMapping.cs b/src/Sentry.Log4Net/LevelMapping.cs index e22dc24d7e..1e31f0ab94 100644 --- a/src/Sentry.Log4Net/LevelMapping.cs +++ b/src/Sentry.Log4Net/LevelMapping.cs @@ -29,4 +29,20 @@ internal static class LevelMapping _ => null }; } + + public static SentryLogLevel? ToSentryLogLevel(this LoggingEvent loggingEvent) + { + return loggingEvent.Level switch + { + var level when level == Level.Off => null, + var level when level >= Level.Fatal => SentryLogLevel.Fatal, + var level when level >= Level.Error => SentryLogLevel.Error, + var level when level >= Level.Warn => SentryLogLevel.Warning, + var level when level >= Level.Info => SentryLogLevel.Info, + var level when level >= Level.Debug => SentryLogLevel.Debug, + var level when level >= Level.Trace => SentryLogLevel.Trace, + var level when level >= Level.All => SentryLogLevel.Trace, + _ => null, + }; + } } diff --git a/src/Sentry.Log4Net/Sentry.Log4Net.csproj b/src/Sentry.Log4Net/Sentry.Log4Net.csproj index aebe69096e..05c913f23a 100644 --- a/src/Sentry.Log4Net/Sentry.Log4Net.csproj +++ b/src/Sentry.Log4Net/Sentry.Log4Net.csproj @@ -26,4 +26,10 @@ + + + SentryAppender.cs + + + diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs new file mode 100644 index 0000000000..f0230dafc5 --- /dev/null +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -0,0 +1,57 @@ +namespace Sentry.Log4Net; + +public partial class SentryAppender +{ + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LoggingEvent loggingEvent, string? environment, bool sendIdentity) + { + if (loggingEvent.ToSentryLogLevel() is not { } level) + { + return; + } + + DateTimeOffset timestamp = new(loggingEvent.TimeStampUtc); + const string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + + var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; + var log = SentryLog.Create(hub, timestamp, level, message, template, parameters); + + var scope = hub.GetScope(); + log.SetDefaultAttributes(options, scope, Sdk); + log.SetOrigin("auto.log.log4net"); + + // Honor the appender-level settings, overriding the scope/options defaults, to match the SentryEvent path. + if (!string.IsNullOrWhiteSpace(environment)) + { + log.SetAttribute("sentry.environment", environment!); + } + + if (sendIdentity && !string.IsNullOrEmpty(loggingEvent.Identity)) + { + log.SetAttribute("user.id", loggingEvent.Identity); + } + + if (loggingEvent.LoggerName is { } loggerName) + { + log.SetAttribute("category.name", loggerName); + } + + // GetProperties() is non-null in current log4net, but the sibling GetLoggingEventProperties guards + // against null, so we do too rather than rely on log4net's internals. + if (loggingEvent.GetProperties() is { } properties) + { + foreach (var property in properties) + { + if (property is DictionaryEntry { Key: string key, Value: { } value }) + { + if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase) && !Guid.TryParse(key, out _)) + { + log.SetAttribute($"property.{key}", value); + } + } + } + } + + hub.Logger.CaptureLog(log); + } +} diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index 8b8f3c51de..a4bc0367f1 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -5,7 +5,7 @@ namespace Sentry.Log4Net; /// /// Sentry appender for log4net. /// -public class SentryAppender : AppenderSkeleton +public partial class SentryAppender : AppenderSkeleton { private readonly Func _initAction; private volatile IDisposable? _sdkHandle; @@ -15,6 +15,12 @@ public class SentryAppender : AppenderSkeleton internal static readonly SdkVersion NameAndVersion = typeof(SentryAppender).Assembly.GetNameAndVersion(); + private static readonly SdkVersion Sdk = new() + { + Name = SdkName, + Version = NameAndVersion.Version, + }; + private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; private readonly IHub _hub; @@ -84,21 +90,39 @@ protected override void Append(LoggingEvent loggingEvent) } } + CaptureStructuredLogIfEnabled(loggingEvent); + var exception = loggingEvent.ExceptionObject ?? loggingEvent.MessageObject as Exception; if (MinimumEventLevel is not null && loggingEvent.Level < MinimumEventLevel) { - var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; - var category = loggingEvent.LoggerName; - var level = loggingEvent.ToBreadcrumbLevel(); - IDictionary data = GetLoggingEventProperties(loggingEvent) - .Where(kvp => kvp.Value != null) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.ToString() ?? ""); + AddBreadcrumbFromLoggingEvent(loggingEvent); + return; + } - _hub.AddBreadcrumb(message, category, type: null, data, level ?? default); + CreateSentryEvent(loggingEvent, exception); + } + + private void CaptureStructuredLogIfEnabled(LoggingEvent loggingEvent) + { + var options = _hub.GetSentryOptions(); + if (options is not { EnableLogs: true }) + { return; } + try + { + CaptureStructuredLog(_hub, options, loggingEvent, Environment, SendIdentity); + } + catch (Exception ex) + { + options.DiagnosticLogger?.LogError(ex, "Failed to capture structured log. The log will be dropped."); + } + } + + private void CreateSentryEvent(LoggingEvent loggingEvent, Exception? exception) + { var evt = new SentryEvent(exception) { Logger = loggingEvent.LoggerName, @@ -139,6 +163,19 @@ protected override void Append(LoggingEvent loggingEvent) _hub.CaptureEvent(evt); } + private void AddBreadcrumbFromLoggingEvent(LoggingEvent loggingEvent) + { + var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; + var category = loggingEvent.LoggerName; + var level = loggingEvent.ToBreadcrumbLevel(); + IDictionary data = GetLoggingEventProperties(loggingEvent) + .Where(kvp => kvp.Value != null) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.ToString() ?? ""); + + _hub.AddBreadcrumb(message, category, type: null, data, level ?? default); + return; + } + private static IEnumerable> GetLoggingEventProperties(LoggingEvent loggingEvent) { var properties = loggingEvent.GetProperties(); diff --git a/src/Sentry.Maui/Internal/ScreenshotAttachment.cs b/src/Sentry.Maui/Internal/ScreenshotAttachment.cs index 6d8a2e7cf6..df18fba25f 100644 --- a/src/Sentry.Maui/Internal/ScreenshotAttachment.cs +++ b/src/Sentry.Maui/Internal/ScreenshotAttachment.cs @@ -5,22 +5,13 @@ namespace Sentry.Maui.Internal; internal class ScreenshotAttachment : SentryAttachment { public ScreenshotAttachment(SentryMauiOptions options) - : this( + : base( AttachmentType.Default, new ScreenshotAttachmentContent(options), "screenshot.jpg", "image/jpeg") { } - - private ScreenshotAttachment( - AttachmentType type, - IAttachmentContent content, - string fileName, - string? contentType) - : base(type, content, fileName, contentType) - { - } } internal class ScreenshotAttachmentContent : IAttachmentContent diff --git a/src/Sentry.NLog/LogLevelExtensions.cs b/src/Sentry.NLog/LogLevelExtensions.cs index 4050ff0c9e..4737e680d4 100644 --- a/src/Sentry.NLog/LogLevelExtensions.cs +++ b/src/Sentry.NLog/LogLevelExtensions.cs @@ -29,4 +29,19 @@ public static BreadcrumbLevel ToBreadcrumbLevel(this LogLevel level) _ => BreadcrumbLevel.Info }; } + + public static SentryLogLevel? ToSentryLogLevel(this LogLevel level) + { + return level.Name switch + { + nameof(LogLevel.Trace) => SentryLogLevel.Trace, + nameof(LogLevel.Debug) => SentryLogLevel.Debug, + nameof(LogLevel.Info) => SentryLogLevel.Info, + nameof(LogLevel.Warn) => SentryLogLevel.Warning, + nameof(LogLevel.Error) => SentryLogLevel.Error, + nameof(LogLevel.Fatal) => SentryLogLevel.Fatal, + nameof(LogLevel.Off) => null, + _ => null, + }; + } } diff --git a/src/Sentry.NLog/Sentry.NLog.csproj b/src/Sentry.NLog/Sentry.NLog.csproj index 85976c7124..66aa9ef4b2 100644 --- a/src/Sentry.NLog/Sentry.NLog.csproj +++ b/src/Sentry.NLog/Sentry.NLog.csproj @@ -35,4 +35,10 @@ + + + SentryTarget.cs + + + diff --git a/src/Sentry.NLog/SentryTarget.Structured.cs b/src/Sentry.NLog/SentryTarget.Structured.cs new file mode 100644 index 0000000000..66de2c3b4d --- /dev/null +++ b/src/Sentry.NLog/SentryTarget.Structured.cs @@ -0,0 +1,91 @@ +namespace Sentry.NLog; + +public sealed partial class SentryTarget +{ + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LogEventInfo logEvent) + { + if (logEvent.Level.ToSentryLogLevel() is not { } level) + { + return; + } + + DateTimeOffset timestamp = new(logEvent.TimeStamp); + GetStructuredLoggingParametersAndAttributes(logEvent, out var parameters, out var attributes); + + var log = SentryLog.Create(hub, timestamp, level, logEvent.FormattedMessage, logEvent.Message, parameters); + + var scope = hub.GetScope(); + log.SetDefaultAttributes(options, scope, Sdk); + log.SetOrigin("auto.log.nlog"); + + if (logEvent.LoggerName is not null) + { + log.Attributes.SetAttribute("category.name", logEvent.LoggerName); + } + + foreach (var attribute in attributes) + { + log.SetAttribute(attribute.Key, attribute.Value); + } + + hub.Logger.CaptureLog(log); + } + + private static void GetStructuredLoggingParametersAndAttributes(LogEventInfo logEvent, out ImmutableArray> parameters, out List> attributes) + { + parameters = GetParameters(logEvent, out var parameterNames); + attributes = []; + + if (!logEvent.HasProperties) + { + return; + } + + foreach (var property in logEvent.Properties) + { + if (property.Key is string key && !string.IsNullOrWhiteSpace(key) && + property.Value is { } value && + !parameterNames.Contains(key)) + { + attributes.Add(new KeyValuePair($"property.{key}", value)); + } + } + } + + private static ImmutableArray> GetParameters(LogEventInfo logEvent, out HashSet parameterNames) + { + var parameters = logEvent.MessageTemplateParameters; + + if (parameters.Count == 0) + { + parameterNames = new HashSet(); + return ImmutableArray>.Empty; + } + + // The HashSet capacity constructor is unavailable on netstandard2.0 and net462 (added in net472). +#if NETSTANDARD2_0 || NET462 + parameterNames = new HashSet(); +#else + parameterNames = new HashSet(parameters.Count); +#endif + + var @params = ImmutableArray.CreateBuilder>(parameters.Count); + + var index = 0; + foreach (var parameter in parameters) + { + // NLog allows passing unnamed holes (e.g. `{}`) - parameters with no names. + // To prevent a collision on the attribute key when multiple unnamed holes are present, we fall back to the + // positional index of the parameter in these cases (matching the behaviour of MEL). + var name = string.IsNullOrEmpty(parameter.Name) + ? index.ToString(CultureInfo.InvariantCulture) + : parameter.Name; + + parameterNames.Add(name); + @params.Add(new KeyValuePair(name, parameter.Value)); + index++; + } + + return @params.DrainToImmutable(); + } +} diff --git a/src/Sentry.NLog/SentryTarget.cs b/src/Sentry.NLog/SentryTarget.cs index b799e525ab..e1777e49ff 100644 --- a/src/Sentry.NLog/SentryTarget.cs +++ b/src/Sentry.NLog/SentryTarget.cs @@ -4,7 +4,7 @@ namespace Sentry.NLog; /// Sentry NLog Target. /// [Target("Sentry")] -public sealed class SentryTarget : TargetWithContext +public sealed partial class SentryTarget : TargetWithContext { // For testing: internal Func HubAccessor { get; } @@ -14,6 +14,12 @@ public sealed class SentryTarget : TargetWithContext internal static readonly SdkVersion NameAndVersion = typeof(SentryTarget).Assembly.GetNameAndVersion(); + private static readonly SdkVersion Sdk = new() + { + Name = Constants.SdkName, + Version = NameAndVersion.Version, + }; + internal static readonly string AdditionalGroupingKeyProperty = "AdditionalGroupingKey"; private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; @@ -129,6 +135,15 @@ public string MinimumBreadcrumbLevel set => Options.MinimumBreadcrumbLevel = LogLevel.FromString(value); } + /// + /// Controls whether logs are generated and sent. + /// + public bool EnableLogs + { + get => Options.EnableLogs; + set => Options.EnableLogs = value; + } + /// /// Whether the NLog integration should initialize the SDK. /// @@ -321,129 +336,155 @@ private void InnerWrite(LogEventInfo logEvent) } var hub = HubAccessor(); - if (!hub.IsEnabled) { return; } var exception = logEvent.Exception; - var shouldOnlyLogExceptions = exception == null && IgnoreEventsWithNoException; + var shouldIgnoreEvent = exception == null && IgnoreEventsWithNoException; var shouldIncludeProperties = ContextProperties?.Count > 0 || ShouldIncludeProperties(logEvent); + var addedBreadcrumbForException = false; - if (logEvent.Level >= Options.MinimumEventLevel && !shouldOnlyLogExceptions) + if (logEvent.Level >= Options.MinimumEventLevel && !shouldIgnoreEvent) { - var formatted = RenderLogEvent(Layout, logEvent); - var template = logEvent.Message; + CreateSentryEvent(logEvent, exception, shouldIncludeProperties, hub); - var evt = new SentryEvent(exception) + // Capturing exception events adds a breadcrumb automatically... we don't want to add another one + if (exception != null) { - Message = new SentryMessage - { - Formatted = formatted, - Message = template - }, - Logger = logEvent.LoggerName, - Level = logEvent.Level.ToSentryLevel(), - Release = Options.Release, - Environment = Options.Environment, - User = GetUser(logEvent) ?? new SentryUser(), - }; + addedBreadcrumbForException = true; + } + } - if (evt.Sdk is { } sdk) - { - sdk.Name = Constants.SdkName; - sdk.Version = NameAndVersion.Version; + // Whether or not it was sent as an event, add breadcrumb so the next event includes it + if (!addedBreadcrumbForException && logEvent.Level >= Options.MinimumBreadcrumbLevel) + { + CreateBreadcrumb(logEvent, exception, shouldIncludeProperties, hub); + } - if (NameAndVersion.Version is { } version) - { - sdk.AddPackage(ProtocolPackageName, version); - } + // Read the options from the Hub rather than the Target's NLog-Options because 'EnableLogs' is declared in the + // base 'SentryOptions', rather than the derived 'SentryNLogOptions'. If the NLog-Target is added without a DSN + // (i.e. without initialising the SDK), then base options will only be initialised in the Hub options. + var sentryOptions = hub.GetSentryOptions(); + if (sentryOptions?.EnableLogs is true) + { + try + { + CaptureStructuredLog(hub, sentryOptions, logEvent); } - - if (Tags.Count > 0 || IncludeEventPropertiesAsTags && logEvent.HasProperties) + catch (Exception ex) { - evt.SetTags(GetTagsFromLogEvent(logEvent)); + sentryOptions.DiagnosticLogger?.LogError(ex, "Failed to capture structured log. The log will be dropped."); } + } + } + + private void CreateBreadcrumb(LogEventInfo logEvent, Exception? exception, bool shouldIncludeProperties, IHub hub) + { + var breadcrumbFormatted = RenderLogEvent(BreadcrumbLayout, logEvent); + var breadcrumbCategory = RenderLogEvent(BreadcrumbCategory, logEvent); + if (string.IsNullOrEmpty(breadcrumbCategory)) + { + breadcrumbCategory = null; + } + + var message = string.IsNullOrWhiteSpace(breadcrumbFormatted) + ? exception?.Message ?? logEvent.FormattedMessage + : breadcrumbFormatted; + + IDictionary? data = null; + // If this is true, an exception is being logged with no custom message + if (exception?.Message != null && !message.StartsWith(exception.Message)) + { + // Exception won't be used as Breadcrumb message. Avoid losing it by adding as data: + data = new Dictionary + { + {"exception_type", exception.GetType().ToString()}, + {"exception_message", exception.Message}, + }; + } + if (IncludeEventDataOnBreadcrumbs) + { if (shouldIncludeProperties) { var contextProps = GetAllProperties(logEvent); - evt.SetExtras(contextProps); - - if (contextProps.TryGetValue(AdditionalGroupingKeyProperty, out var additionalGroupingKey) - && additionalGroupingKey is string groupingKey) + data ??= new Dictionary(contextProps.Count); + foreach (var contextProp in contextProps) { - var overridenFingerprint = evt.Fingerprint.ToList(); - if (!evt.Fingerprint.Any()) + if (contextProp.Value?.ToString() is { } value) { - overridenFingerprint.Add("{{ default }}"); + data.Add(contextProp.Key, value); } - - overridenFingerprint.Add(groupingKey); - - evt.SetFingerprint(overridenFingerprint); } } + } - _ = hub.CaptureEvent(evt); + hub.AddBreadcrumb( + _clock, + message, + breadcrumbCategory, + data: data, + level: logEvent.Level.ToBreadcrumbLevel()); + } - // Capturing exception events adds a breadcrumb automatically... we don't want to add another one - if (exception != null) - { - return; - } - } + private void CreateSentryEvent(LogEventInfo logEvent, Exception? exception, bool shouldIncludeProperties, IHub hub) + { + var formatted = RenderLogEvent(Layout, logEvent); + var template = logEvent.Message; - // Whether or not it was sent as event, add breadcrumb so the next event includes it - if (logEvent.Level >= Options.MinimumBreadcrumbLevel) + var evt = new SentryEvent(exception) { - var breadcrumbFormatted = RenderLogEvent(BreadcrumbLayout, logEvent); - var breadcrumbCategory = RenderLogEvent(BreadcrumbCategory, logEvent); - if (string.IsNullOrEmpty(breadcrumbCategory)) + Message = new SentryMessage { - breadcrumbCategory = null; - } + Formatted = formatted, + Message = template + }, + Logger = logEvent.LoggerName, + Level = logEvent.Level.ToSentryLevel(), + Release = Options.Release, + Environment = Options.Environment, + User = GetUser(logEvent) ?? new SentryUser(), + }; - var message = string.IsNullOrWhiteSpace(breadcrumbFormatted) - ? exception?.Message ?? logEvent.FormattedMessage - : breadcrumbFormatted; + if (evt.Sdk is { } sdk) + { + sdk.Name = Constants.SdkName; + sdk.Version = NameAndVersion.Version; - IDictionary? data = null; - // If this is true, an exception is being logged with no custom message - if (exception?.Message != null && !message.StartsWith(exception.Message)) + if (NameAndVersion.Version is { } version) { - // Exception won't be used as Breadcrumb message. Avoid losing it by adding as data: - data = new Dictionary - { - {"exception_type", exception.GetType().ToString()}, - {"exception_message", exception.Message}, - }; + sdk.AddPackage(ProtocolPackageName, version); } + } - if (IncludeEventDataOnBreadcrumbs) + if (Tags.Count > 0 || IncludeEventPropertiesAsTags && logEvent.HasProperties) + { + evt.SetTags(GetTagsFromLogEvent(logEvent)); + } + + if (shouldIncludeProperties) + { + var contextProps = GetAllProperties(logEvent); + evt.SetExtras(contextProps); + + if (contextProps.TryGetValue(AdditionalGroupingKeyProperty, out var additionalGroupingKey) + && additionalGroupingKey is string groupingKey) { - if (shouldIncludeProperties) + var overridenFingerprint = evt.Fingerprint.ToList(); + if (!evt.Fingerprint.Any()) { - var contextProps = GetAllProperties(logEvent); - data ??= new Dictionary(contextProps.Count); - foreach (var contextProp in contextProps) - { - if (contextProp.Value?.ToString() is { } value) - { - data.Add(contextProp.Key, value); - } - } + overridenFingerprint.Add("{{ default }}"); } - } - hub.AddBreadcrumb( - _clock, - message, - breadcrumbCategory, - data: data, - level: logEvent.Level.ToBreadcrumbLevel()); + overridenFingerprint.Add(groupingKey); + + evt.SetFingerprint(overridenFingerprint); + } } + + _ = hub.CaptureEvent(evt); } private SentryUser? GetUser(LogEventInfo logEvent) diff --git a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs index 6c9113aca2..88a81089c4 100644 --- a/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs +++ b/src/Sentry.OpenTelemetry/SentrySpanProcessor.cs @@ -134,13 +134,15 @@ private void CreateChildSpan(Activity data, ISpan parentSpan, SpanId? parentSpan }; var span = parentSpan.StartChild(context); - // Used to filter out spans that are not recorded when finishing a transaction - span.SetFused(data); + // Fuse the Activity via a WeakReference so _map does not pin it, letting PruneFilteredSpans evict + // never-ended spans. #3198 intended weak refs here but SetFused stores the value strongly. + SetFusedActivity(span, data); if (span is SpanTracer spanTracer) { spanTracer.Origin = OpenTelemetryOrigin; spanTracer.StartTimestamp = data.StartTimeUtc; - spanTracer.IsFiltered = () => spanTracer.GetFused() is { IsAllDataRequested: false, Recorded: false }; + // Used to filter out spans that are not recorded when finishing a transaction. + spanTracer.IsFiltered = () => GetFusedActivity(spanTracer) is { IsAllDataRequested: false, Recorded: false }; } _map[data.SpanId] = span; } @@ -176,7 +178,8 @@ private void CreateRootSpan(Activity data) { scope.Transaction ??= transaction; }, transaction); - transaction.SetFused(data); + // Fuse weakly so _map does not pin the Activity. + SetFusedActivity(transaction, data); _map[data.SpanId] = transaction; } @@ -304,9 +307,9 @@ internal void PruneFilteredSpans(bool force = false) foreach (var mappedItem in _map) { var (spanId, span) = mappedItem; - var activity = span.GetFused(); - // Also prune when the activity has been GC'd (weak ref returns null): the activity is gone, so it - // can never call OnEnd, and the span will never be removed otherwise — causing a memory leak. + var activity = GetFusedActivity(span); + // Prune when the activity has been GC'd (weak ref returns null): it can no longer call OnEnd, so + // the span would otherwise stay in _map forever. if (activity is null or { Recorded: false, IsAllDataRequested: false }) { _map.TryRemove(spanId, out _); @@ -314,6 +317,18 @@ internal void PruneFilteredSpans(bool force = false) } } + private const string ActivityPropertyName = "Activity"; + + // Fuses the Activity onto the span via a WeakReference so _map does not pin it. + private static void SetFusedActivity(ISpan span, Activity data) => + span.SetFused(ActivityPropertyName, new WeakReference(data)); + + // Returns the fused Activity, or null once it has been garbage-collected. + private static Activity? GetFusedActivity(ISpan span) => + span.GetFused>(ActivityPropertyName) is { } weakRef && weakRef.TryGetTarget(out var activity) + ? activity + : null; + private bool NeedsPruning() { var lastPruned = Interlocked.Read(ref _lastPruned); diff --git a/src/Sentry.Serilog/SentrySerilogOptions.cs b/src/Sentry.Serilog/SentrySerilogOptions.cs index 144d88d806..b06432736a 100644 --- a/src/Sentry.Serilog/SentrySerilogOptions.cs +++ b/src/Sentry.Serilog/SentrySerilogOptions.cs @@ -40,4 +40,16 @@ public class SentrySerilogOptions : SentryOptions /// Optional /// public ITextFormatter? TextFormatter { get; set; } + + /// + /// The minimum level for events passed through the sink. Ignored when is specified. + /// + /// + public LogEventLevel RestrictedToMinimumLevel { get; set; } = LevelAlias.Minimum; + + /// + /// A switch allowing the pass-through minimum level to be changed at runtime. + /// + /// + public LoggingLevelSwitch? LevelSwitch { get; set; } } diff --git a/src/Sentry.Serilog/SentrySinkExtensions.cs b/src/Sentry.Serilog/SentrySinkExtensions.cs index 7a823c3d89..ea363b4c9e 100644 --- a/src/Sentry.Serilog/SentrySinkExtensions.cs +++ b/src/Sentry.Serilog/SentrySinkExtensions.cs @@ -36,6 +36,8 @@ public static class SentrySinkExtensions /// What modes to use for event automatic de-duplication. /// Default tags to add to all events. /// Whether to send structured logs. + /// The minimum level for events passed through the sink. Ignored when is specified. + /// A switch allowing the pass-through minimum level to be changed at runtime. /// /// This sample shows how each item may be set from within a configuration file: /// @@ -106,7 +108,9 @@ public static LoggerConfiguration Sentry( ReportAssembliesMode? reportAssembliesMode = null, DeduplicateMode? deduplicateMode = null, Dictionary? defaultTags = null, - bool? enableLogs = null) + bool? enableLogs = null, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + LoggingLevelSwitch? levelSwitch = null) { return loggerConfiguration.Sentry(o => ConfigureSentrySerilogOptions(o, dsn, @@ -132,7 +136,9 @@ public static LoggerConfiguration Sentry( reportAssembliesMode, deduplicateMode, defaultTags, - enableLogs)); + enableLogs, + restrictedToMinimumLevel, + levelSwitch)); } /// @@ -147,6 +153,8 @@ public static LoggerConfiguration Sentry( /// Minimum log level to record a breadcrumb. /// The Serilog format provider. /// The Serilog text formatter. + /// The minimum level for events passed through the sink. Ignored when is specified. + /// A switch allowing the pass-through minimum level to be changed at runtime. /// /// This sample shows how each item may be set from within a configuration file: /// @@ -174,15 +182,18 @@ public static LoggerConfiguration Sentry( LogEventLevel? minimumEventLevel = null, LogEventLevel? minimumBreadcrumbLevel = null, IFormatProvider? formatProvider = null, - ITextFormatter? textFormatter = null - ) + ITextFormatter? textFormatter = null, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + LoggingLevelSwitch? levelSwitch = null) { return loggerConfiguration.Sentry(o => ConfigureSentrySerilogOptions(o, null, minimumEventLevel, minimumBreadcrumbLevel, formatProvider, - textFormatter)); + textFormatter, + restrictedToMinimumLevel: restrictedToMinimumLevel, + levelSwitch: levelSwitch)); } internal static void ConfigureSentrySerilogOptions( @@ -210,7 +221,9 @@ internal static void ConfigureSentrySerilogOptions( ReportAssembliesMode? reportAssembliesMode = null, DeduplicateMode? deduplicateMode = null, Dictionary? defaultTags = null, - bool? enableLogs = null) + bool? enableLogs = null, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + LoggingLevelSwitch? levelSwitch = null) { if (dsn is not null) { @@ -327,6 +340,9 @@ internal static void ConfigureSentrySerilogOptions( sentrySerilogOptions.EnableLogs = enableLogs.Value; } + sentrySerilogOptions.RestrictedToMinimumLevel = restrictedToMinimumLevel; + sentrySerilogOptions.LevelSwitch = levelSwitch; + // Serilog-specific items sentrySerilogOptions.InitializeSdk = dsn is not null; // Inferred from the Sentry overload that is used if (defaultTags?.Count > 0) @@ -364,6 +380,6 @@ public static LoggerConfiguration Sentry( sdkDisposable = SentrySdk.Init(options); } - return loggerConfiguration.Sink(new SentrySink(options, sdkDisposable)); + return loggerConfiguration.Sink(new SentrySink(options, sdkDisposable), options.RestrictedToMinimumLevel, options.LevelSwitch); } } diff --git a/src/Sentry/Ben.BlockingDetector/TaskBlockingListener.cs b/src/Sentry/Ben.BlockingDetector/TaskBlockingListener.cs index f63fa23693..3fbdacefc5 100644 --- a/src/Sentry/Ben.BlockingDetector/TaskBlockingListener.cs +++ b/src/Sentry/Ben.BlockingDetector/TaskBlockingListener.cs @@ -12,8 +12,7 @@ internal class TaskBlockingListener : EventListener private readonly IBlockingMonitor _monitor; private readonly ITaskBlockingListenerState _state; - private static Lazy LazyDefaultState => new(); - internal static StaticTaskBlockingListenerState DefaultState => LazyDefaultState.Value; + internal static StaticTaskBlockingListenerState DefaultState { get; } = new(); public TaskBlockingListener(IBlockingMonitor monitor) : this(monitor, null) diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 07639f4eea..061378afb5 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -61,6 +61,81 @@ public static ISpan StartSpan(this IHub hub, string operation, string descriptio : hub.StartTransaction(operation, description); // this is actually in the wrong order but changing it may break other things } + /// + /// Records a transaction that has already completed elsewhere — for example, spans measured on another + /// machine or process and replayed through a proxy. Timing is supplied explicitly rather than measured + /// live, so unlike there is no stopwatch, idle timer, + /// or sampling decision involved; the transaction is materialized and captured once when + /// returns. + /// + /// The hub. + /// The transaction name. + /// The transaction operation. + /// When the transaction started. + /// How long the transaction ran. Must not be negative. + /// Optional trace id to preserve from the originating system; generated when omitted. + /// Optional root span id to preserve; generated when omitted. + /// Optional parent span id, when continuing a trace from another service. + /// + /// Optional callback to set metadata, configure the capture scope, and record the span tree via + /// . + /// + /// The event id of the captured transaction. + public static SentryId RecordTransaction( + this IHub hub, + string name, + string operation, + DateTimeOffset startTimestamp, + TimeSpan duration, + SentryId? traceId = null, + SpanId? spanId = null, + SpanId? parentSpanId = null, + Action? configure = null) + { + if (duration < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(duration), duration, "Transaction duration cannot be negative."); + } + + var context = new TransactionContext( + name, + operation, + spanId: spanId, + parentSpanId: parentSpanId, + traceId: traceId, + isSampled: true); + + var tracer = new TransactionTracer(hub, context) + { + StartTimestamp = startTimestamp, + EndTimestamp = startTimestamp + duration, + }; + tracer.Status ??= SpanStatus.Ok; + + // A recorded transaction represents work that happened elsewhere, so we capture it against a clean + // scope rather than the current (live) one — this stops the current process's breadcrumbs/user/tags/ + // contexts from leaking onto it. The scope is exposed via ITransactionRecorder.ConfigureScope so the + // caller can still set data that was captured alongside the original trace. + var options = hub.GetSentryOptions(); + var scope = options is not null ? new Scope(options) : null; + + var recorder = new TransactionRecorder(tracer, scope); + configure?.Invoke(recorder); + + var transaction = new SentryTransaction(tracer); + if (scope is not null) + { + hub.CaptureTransaction(transaction, scope, null); + } + else + { + // No options available (e.g. a disabled hub); this is effectively a no-op capture. + hub.CaptureTransaction(transaction); + } + + return transaction.EventId; + } + /// /// Adds a breadcrumb to the current scope. /// diff --git a/src/Sentry/IScopeObserver.cs b/src/Sentry/IScopeObserver.cs index 870a160a30..3d9dcdce90 100644 --- a/src/Sentry/IScopeObserver.cs +++ b/src/Sentry/IScopeObserver.cs @@ -35,6 +35,11 @@ public interface IScopeObserver /// public void SetTrace(SentryId traceId, SpanId parentSpanId); + /// + /// Sets the environment. + /// + public void SetEnvironment(string? environment); + /// /// Adds an attachment. /// diff --git a/src/Sentry/ISpanRecorder.cs b/src/Sentry/ISpanRecorder.cs new file mode 100644 index 0000000000..f62f9f0349 --- /dev/null +++ b/src/Sentry/ISpanRecorder.cs @@ -0,0 +1,87 @@ +namespace Sentry; + +/// +/// Builds a span while recording a transaction that has already completed elsewhere (for example, work +/// measured on another machine and replayed through a proxy). Unlike , timing is supplied +/// up-front rather than measured live, so a recorded span can never be left half-specified. +/// +/// +/// Obtained from or a parent via +/// . +/// See . +/// +public interface ISpanRecorder +{ + /// + /// The span's id. When not overridden at creation, one is generated. + /// + public SpanId SpanId { get; } + + /// + /// Span description. + /// + public string? Description { get; set; } + + /// + /// Span status. Defaults to when not set. + /// + public SpanStatus? Status { get; set; } + + /// + /// Sets a tag on the span. + /// + public void SetTag(string key, string value); + + /// + /// Sets arbitrary data on the span. + /// + public void SetData(string key, object? value); + + /// + /// Records a child span nested under this one. The parent is structural (this span), so no parent id + /// needs to be supplied. Pass to preserve an id from the originating system. + /// + /// The span operation. + /// When the child span started. + /// How long the child span ran. Must not be negative. + /// Optional span id to preserve; generated when omitted. + /// Optional callback to set metadata and record further nested spans. + /// The recorder for the child span. + public ISpanRecorder RecordSpan( + string operation, + DateTimeOffset startTimestamp, + TimeSpan duration, + SpanId? spanId = null, + Action? configure = null); +} + +/// +/// Builds a transaction (and its span tree) that has already completed elsewhere. Obtained inside the +/// configure callback of . When the callback returns, the +/// whole tree is materialized and captured once — no live tracing, stopwatch, or sampling roll is involved. +/// +public interface ITransactionRecorder : ISpanRecorder +{ + /// + /// The trace this transaction belongs to. Set at creation (via ) + /// and inherited by every recorded span. + /// + public SentryId TraceId { get; } + + /// + /// The release that produced this transaction. Useful when the origin system differs from this process. + /// + public string? Release { get; set; } + + /// + /// The environment the transaction ran in. Useful when the origin system differs from this process. + /// + public string? Environment { get; set; } + + /// + /// Configures the (clean) scope the recorded transaction is captured against. Use this to set data that + /// was captured alongside the original trace — user, tags, contexts, breadcrumbs — without inheriting the + /// current process's live scope. + /// + public void ConfigureScope(Action configureScope); +} diff --git a/src/Sentry/Internal/BackpressureMonitor.cs b/src/Sentry/Internal/BackpressureMonitor.cs index c45b38264d..1126808ee8 100644 --- a/src/Sentry/Internal/BackpressureMonitor.cs +++ b/src/Sentry/Internal/BackpressureMonitor.cs @@ -36,11 +36,14 @@ internal class BackpressureMonitor : IDisposable private readonly CancellationTokenSource _cts = new(); private readonly Task _workerTask; + private int _disposed; + internal Task WorkerTask => _workerTask; internal int DownsampleLevel => _downsampleLevel; internal long LastQueueOverflowTicks => Interlocked.Read(ref _lastQueueOverflow); internal long LastRateLimitEventTicks => Interlocked.Read(ref _lastRateLimitEvent); - public BackpressureMonitor(IDiagnosticLogger? logger, ISystemClock? clock = null, bool enablePeriodicHealthCheck = true) + public BackpressureMonitor(IDiagnosticLogger? logger, ISystemClock? clock = null, bool enablePeriodicHealthCheck = true, + TaskScheduler? scheduler = null) { _logger = logger; _clock = clock ?? SystemClock.Clock; @@ -48,7 +51,13 @@ public BackpressureMonitor(IDiagnosticLogger? logger, ISystemClock? clock = null if (enablePeriodicHealthCheck) { _logger?.LogDebug("Starting BackpressureMonitor."); - _workerTask = Task.Run(() => DoWorkAsync(_cts.Token)); + // Equivalent to Task.Run(() => DoWorkAsync(_cts.Token)): same creation options (DenyChildAttach) and, + // by default, the same scheduler (the thread pool). Tests inject a scheduler to model single-threaded + // runtimes (e.g. Unity WebGL) where the worker and its continuations must run on the same thread. + scheduler ??= TaskScheduler.Default; + _workerTask = Task.Factory + .StartNew(() => DoWorkAsync(_cts.Token), CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler) + .Unwrap(); } else { @@ -144,14 +153,20 @@ internal bool IsHealthy public void Dispose() { - try + // Idempotent and thread-safe: only the first caller runs the disposal logic. Without this guard a + // second call would hit an already-disposed _cts and log a spurious ObjectDisposedException. + if (Interlocked.Exchange(ref _disposed, 1) != 0) { - _cts.Cancel(); - _workerTask.Wait(); + return; } - catch (AggregateException ex) when (ex.InnerException is OperationCanceledException) + + try { - // Ignore cancellation + // Request cancellation but do NOT block on _workerTask here. On single-threaded runtimes + // (e.g. Unity WebGL / browser-wasm) _workerTask.Wait() would cause a deadlock. + // The worker observes the token and unwinds on its own. + // See https://github.com/getsentry/sentry-dotnet/issues/5237 + _cts.Cancel(); } catch (Exception ex) { @@ -160,7 +175,15 @@ public void Dispose() } finally { - _cts.Dispose(); + // Dispose the CTS once the worker has stopped using the token, but + // without blocking the calling thread. Disposing it inline would race the worker + // and could lead to an ObjectDisposedException + _workerTask.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), + _cts, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); } } } diff --git a/src/Sentry/Internal/BatchProcessor.cs b/src/Sentry/Internal/BatchProcessor.cs index e34148bac9..04ce2b54a8 100644 --- a/src/Sentry/Internal/BatchProcessor.cs +++ b/src/Sentry/Internal/BatchProcessor.cs @@ -67,7 +67,7 @@ internal void Enqueue(TItem item) activeBuffer = ReferenceEquals(activeBuffer, _buffer1) ? _buffer2 : _buffer1; if (!TryEnqueue(activeBuffer, item)) { - _clientReportRecorder.RecordDiscardedEvent(DiscardReason.Backpressure, DataCategory.Default, 1); + _clientReportRecorder.RecordDiscardedEvent(DiscardReason.Backpressure, DiscardCategory, 1); _diagnosticLogger?.LogInfo("{0}-Buffer full ... dropping {0}", item.GetType().Name); } } @@ -131,6 +131,11 @@ private void CaptureItems(BatchBuffer buffer) protected abstract void CaptureEnvelope(IHub hub, TItem[] items); + /// + /// The data category used when recording dropped items as client reports. + /// + protected abstract DataCategory DiscardCategory { get; } + private void OnTimeoutExceeded(BatchBuffer buffer) { if (!buffer.IsEmpty) @@ -158,6 +163,8 @@ protected override void CaptureEnvelope(IHub hub, SentryLog[] items) { _ = hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(items))); } + + protected override DataCategory DiscardCategory => DataCategory.LogItem; } internal sealed class SentryMetricBatchProcessor : BatchProcessor @@ -171,4 +178,6 @@ protected override void CaptureEnvelope(IHub hub, SentryMetric[] items) { _ = hub.CaptureEnvelope(Envelope.FromMetric(new TraceMetric(items))); } + + protected override DataCategory DiscardCategory => DataCategory.TraceMetric; } diff --git a/src/Sentry/Internal/DataCategory.cs b/src/Sentry/Internal/DataCategory.cs index 1b15e2310e..f7349059ef 100644 --- a/src/Sentry/Internal/DataCategory.cs +++ b/src/Sentry/Internal/DataCategory.cs @@ -2,15 +2,19 @@ namespace Sentry.Internal; internal readonly struct DataCategory : IEnumeration { - // See https://develop.sentry.dev/sdk/rate-limiting/#definitions for list + // See https://develop.sentry.dev/sdk/expected-features/rate-limiting/#definitions for list public static DataCategory Attachment = new("attachment"); public static DataCategory Default = new("default"); public static DataCategory Error = new("error"); public static DataCategory Feedback = new("feedback"); public static DataCategory Internal = new("internal"); + public static DataCategory LogItem = new("log_item"); + public static DataCategory MetricBucket = new("metric_bucket"); + public static DataCategory Monitor = new("monitor"); public static DataCategory Security = new("security"); public static DataCategory Session = new("session"); public static DataCategory Span = new("span"); + public static DataCategory TraceMetric = new("trace_metric"); public static DataCategory Transaction = new("transaction"); public static DataCategory Profile = new("profile"); diff --git a/src/Sentry/Internal/Http/RateLimitCategory.cs b/src/Sentry/Internal/Http/RateLimitCategory.cs index cecea51561..02504d3e66 100644 --- a/src/Sentry/Internal/Http/RateLimitCategory.cs +++ b/src/Sentry/Internal/Http/RateLimitCategory.cs @@ -10,28 +10,9 @@ internal class RateLimitCategory : IEquatable public RateLimitCategory(string name) => Name = name; - public bool Matches(EnvelopeItem item) - { - if (IsMatchAll) - { - return true; - } - - var type = item.TryGetType(); - if (string.IsNullOrWhiteSpace(type)) - { - return false; - } - - return type switch - { - EnvelopeItem.TypeValueMetric => - // Metrics are a bit unique - the envelope item type is `statsd` but the category is `metric_bucket` - string.Equals(Name, "metric_bucket", StringComparison.OrdinalIgnoreCase), - // For most reporting categories, the envelope item type matches the client report category - _ => string.Equals(Name, type, StringComparison.OrdinalIgnoreCase) - }; - } + public bool Matches(EnvelopeItem item) => + // Rate limits are keyed by data category (e.g. "error", "monitor", "metric_bucket"), not envelope item type. + IsMatchAll || string.Equals(Name, item.DataCategory.ToString(), StringComparison.OrdinalIgnoreCase); public bool Equals(RateLimitCategory? other) { diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 605970ecbb..d235bcc2a6 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -952,8 +952,9 @@ public void Dispose() } //Don't dispose of ScopeManager since we want dangling transactions to still be able to access tags. - // Don't dispose of _backpressureMonitor since we want the client to continue to process envelopes without - // throwing an ObjectDisposedException. + // Stop the backpressure monitor's worker so it doesn't outlive the hub... this cancels the + // worker but clients can still read the downsample factor while draining. + _backpressureMonitor?.Dispose(); #if __IOS__ // TODO diff --git a/src/Sentry/Internal/ScopeObserver.cs b/src/Sentry/Internal/ScopeObserver.cs index 28f4676f84..0ba44c98a5 100644 --- a/src/Sentry/Internal/ScopeObserver.cs +++ b/src/Sentry/Internal/ScopeObserver.cs @@ -95,6 +95,15 @@ public void SetTrace(SentryId traceId, SpanId parentSpanId) public abstract void SetTraceImpl(SentryId traceId, SpanId parentSpanId); + public void SetEnvironment(string? environment) + { + _options.DiagnosticLogger?.Log(SentryLevel.Debug, + "{0} Scope Sync - Setting Environment e:\"{1}\"", null, _name, environment); + SetEnvironmentImpl(environment); + } + + public abstract void SetEnvironmentImpl(string? environment); + public void AddAttachment(SentryAttachment attachment) { _options.DiagnosticLogger?.Log(SentryLevel.Debug, diff --git a/src/Sentry/Internal/SpanRecorder.cs b/src/Sentry/Internal/SpanRecorder.cs new file mode 100644 index 0000000000..9da6e1107b --- /dev/null +++ b/src/Sentry/Internal/SpanRecorder.cs @@ -0,0 +1,114 @@ +namespace Sentry.Internal; + +/// +/// Base for a node in a recorded transaction tree. Holds the owning +/// (which new child spans are created on) and the node this recorder +/// represents (a , or the transaction tracer itself for the root), proxying metadata +/// straight through to it. +/// +internal abstract class SpanRecorderBase : ISpanRecorder +{ + /// The transaction that owns the whole tree; child spans are created on it. + protected TransactionTracer Owner { get; } + + private readonly ISpan _node; + + protected SpanRecorderBase(TransactionTracer owner, ISpan node) + { + Owner = owner; + _node = node; + } + + public SpanId SpanId => _node.SpanId; + + public string? Description + { + get => _node.Description; + set => _node.Description = value; + } + + public SpanStatus? Status + { + get => _node.Status; + set => _node.Status = value; + } + + public void SetTag(string key, string value) => _node.SetTag(key, value); + + public void SetData(string key, object? value) => _node.SetData(key, value); + + public ISpanRecorder RecordSpan( + string operation, + DateTimeOffset startTimestamp, + TimeSpan duration, + SpanId? spanId = null, + Action? configure = null) + { + if (duration < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(duration), duration, "Span duration cannot be negative."); + } + + // Reuse the existing child-span machinery so the span is added to the transaction's flat span + // collection with the correct parent pointer; then override the timing the tracer would otherwise + // have measured with a stopwatch. + var span = (SpanTracer)Owner.StartChild(spanId, SpanId, operation); + span.StartTimestamp = startTimestamp; + span.EndTimestamp = startTimestamp + duration; + span.Status ??= SpanStatus.Ok; + + var recorder = new SpanRecorder(Owner, span); + configure?.Invoke(recorder); + return recorder; + } +} + +/// +/// backed by a whose timing has been set explicitly. +/// +internal sealed class SpanRecorder : SpanRecorderBase +{ + public SpanRecorder(TransactionTracer owner, SpanTracer span) + : base(owner, span) + { + } +} + +/// +/// backed by a whose timing has been set +/// explicitly. The tracer is never Finish()-ed (which would reset the live scope); instead the caller +/// converts it to a and captures it directly. +/// +internal sealed class TransactionRecorder : SpanRecorderBase, ITransactionRecorder +{ + private readonly Scope? _scope; + + public TransactionRecorder(TransactionTracer tracer, Scope? scope) + : base(tracer, tracer) + { + _scope = scope; + } + + public SentryId TraceId => Owner.TraceId; + + public string? Release + { + get => Owner.Release; + set => Owner.Release = value; + } + + public string? Environment + { + get => Owner.Environment; + set => Owner.Environment = value; + } + + public void ConfigureScope(Action configureScope) + { + // No scope when options are unavailable (e.g. a disabled hub); configuration is a no-op. + if (_scope is not null) + { + configureScope(_scope); + } + } +} diff --git a/src/Sentry/Platforms/Android/AndroidScopeObserver.cs b/src/Sentry/Platforms/Android/AndroidScopeObserver.cs index e18b215562..c4d3e91640 100644 --- a/src/Sentry/Platforms/Android/AndroidScopeObserver.cs +++ b/src/Sentry/Platforms/Android/AndroidScopeObserver.cs @@ -112,6 +112,18 @@ public void SetTrace(SentryId traceId, SpanId parentSpanId) } } + public void SetEnvironment(string? environment) + { + try + { + // TODO: Missing corresponding scope-level functionality on the Android SDK + } + finally + { + _innerObserver?.SetEnvironment(environment); + } + } + public void AddAttachment(SentryAttachment attachment) { try diff --git a/src/Sentry/Platforms/Cocoa/CocoaScopeObserver.cs b/src/Sentry/Platforms/Cocoa/CocoaScopeObserver.cs index f92156a4be..46f8aebf5e 100644 --- a/src/Sentry/Platforms/Cocoa/CocoaScopeObserver.cs +++ b/src/Sentry/Platforms/Cocoa/CocoaScopeObserver.cs @@ -120,6 +120,18 @@ public void SetTrace(SentryId traceId, SpanId parentSpanId) } } + public void SetEnvironment(string? environment) + { + try + { + SentryCocoaSdk.ConfigureScope(scope => scope.SetEnvironment(environment)); + } + finally + { + _innerObserver?.SetEnvironment(environment); + } + } + public void AddAttachment(SentryAttachment attachment) { try diff --git a/src/Sentry/Platforms/Native/CFunctions.cs b/src/Sentry/Platforms/Native/CFunctions.cs index 5faa149720..72105155e8 100644 --- a/src/Sentry/Platforms/Native/CFunctions.cs +++ b/src/Sentry/Platforms/Native/CFunctions.cs @@ -251,6 +251,9 @@ internal static string GetCacheDirectory(SentryOptions options) [DllImport("sentry-native")] internal static extern void sentry_set_trace(string traceId, string parentSpanId); + [DllImport("sentry-native")] + internal static extern void sentry_set_environment(string? environment); + internal static Dictionary LoadDebugImages(IDiagnosticLogger? logger) { // It only makes sense to load them once because they're cached on the native side anyway. We could force diff --git a/src/Sentry/Platforms/Native/NativeScopeObserver.cs b/src/Sentry/Platforms/Native/NativeScopeObserver.cs index f9034df998..1d6956a9f4 100644 --- a/src/Sentry/Platforms/Native/NativeScopeObserver.cs +++ b/src/Sentry/Platforms/Native/NativeScopeObserver.cs @@ -43,6 +43,9 @@ public override void SetUserImpl(SentryUser user) public override void SetTraceImpl(SentryId traceId, SpanId parentSpanId) => C.sentry_set_trace(traceId.ToString(), parentSpanId.ToString()); + public override void SetEnvironmentImpl(string? environment) => + C.sentry_set_environment(environment); + public override void AddAttachmentImpl(SentryAttachment attachment) { // TODO: Missing corresponding functionality on the Native SDK diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 227c572797..e70a80526f 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -335,9 +335,12 @@ public static Envelope FromFeedback( } /// - /// Creates an envelope that contains a single transaction. + /// Creates an envelope that contains a single transaction and optional attachments. /// - public static Envelope FromTransaction(SentryTransaction transaction) + public static Envelope FromTransaction( + SentryTransaction transaction, + IDiagnosticLogger? logger = null, + IReadOnlyCollection? attachments = null) { var eventId = transaction.EventId; var header = CreateHeader(eventId, transaction.DynamicSamplingContext); @@ -358,6 +361,20 @@ public static Envelope FromTransaction(SentryTransaction transaction) } } + if (attachments is not null) + { + foreach (var attachment in attachments) + { + if (attachment.IsNull()) + { + logger?.LogWarning("Encountered a null attachment. Skipping."); + continue; + } + + AddEnvelopeItemFromAttachment(items, attachment, logger); + } + } + return new Envelope(eventId, header, items); } diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 21ab3ba25d..3c4ad20ae8 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -46,14 +46,26 @@ public sealed class EnvelopeItem : ISerializable, IDisposable TypeValueEvent => DataCategory.Error, // These ones are equivalent + TypeValueFeedback => DataCategory.Feedback, TypeValueTransaction => DataCategory.Transaction, TypeValueSpan => DataCategory.Span, TypeValueSession => DataCategory.Session, TypeValueAttachment => DataCategory.Attachment, TypeValueProfile => DataCategory.Profile, + TypeValueLog => DataCategory.LogItem, + TypeValueTraceMetric => DataCategory.TraceMetric, + + // The "check_in" item type corresponds to the "monitor" data category + TypeValueCheckIn => DataCategory.Monitor, + + // The "statsd" item type corresponds to the "metric_bucket" data category + TypeValueMetric => DataCategory.MetricBucket, + + // Client reports are SDK telemetry that Relay never rate-limits - mapped to internal + TypeValueClientReport => DataCategory.Internal, // Not all envelope item types equate to data categories - // Specifically, user_report and client_report just use "default" + // Specifically, user_report just uses "default" _ => DataCategory.Default }; diff --git a/src/Sentry/Scope.cs b/src/Sentry/Scope.cs index 62b926cb7f..d062290133 100644 --- a/src/Sentry/Scope.cs +++ b/src/Sentry/Scope.cs @@ -145,7 +145,32 @@ public SentryUser User public string? Distribution { get; set; } /// - public string? Environment { get; set; } + public string? Environment + { + get; + set + { + if (field == value) + { + return; + } + + if (value is null) + { + Options.LogDebug("Environment cannot be null. Reverting to default value from the options."); + field = Options.Environment; + } + else + { + field = value; + } + + if (Options is { EnableScopeSync: true, ScopeObserver: { } observer }) + { + observer.SetEnvironment(field); + } + } + } // TransactionName is kept for legacy purposes because // SentryEvent still makes use of it. diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index f0f21c7048..fcb4c25f9e 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -106,13 +106,13 @@ <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) - - - - - - - + + + + + + + diff --git a/src/Sentry/SentryAttachment.cs b/src/Sentry/SentryAttachment.cs index 0249fc589f..c57dc96ed4 100644 --- a/src/Sentry/SentryAttachment.cs +++ b/src/Sentry/SentryAttachment.cs @@ -65,6 +65,12 @@ public class SentryAttachment /// public string? ContentType { get; } + /// + /// Whether the attachment should be added to transactions. + /// Defaults to false. + /// + public bool AddToTransactions { get; } + /// /// Initializes an instance of . /// @@ -73,10 +79,24 @@ public SentryAttachment( IAttachmentContent content, string fileName, string? contentType) + : this(type, content, fileName, contentType, false) + { + } + + /// + /// Initializes an instance of . + /// + public SentryAttachment( + AttachmentType type, + IAttachmentContent content, + string fileName, + string? contentType, + bool addToTransactions) { Type = type; Content = content; FileName = fileName; ContentType = contentType; + AddToTransactions = addToTransactions; } } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index e84ca1eb5f..c3c88a062e 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -236,7 +236,10 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent processedTransaction.Redact(); } - CaptureEnvelope(Envelope.FromTransaction(processedTransaction)); + // Keep null entries so the null-attachment guard in Envelope.FromTransaction handles them + // (consistent with the event/feedback capture paths); dereferencing them here would throw. + var attachments = hint.Attachments.Where(a => a is null || a.AddToTransactions).ToList(); + CaptureEnvelope(Envelope.FromTransaction(processedTransaction, _options.DiagnosticLogger, attachments)); } #if NET6_0_OR_GREATER diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 8e74be21e8..7adce18c53 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -12,7 +12,7 @@ namespace Sentry; /// Sentry .NET SDK Docs: . /// [DebuggerDisplay(@"SentryLog \{ Level = {Level}, Message = '{Message}' \}")] -public sealed class SentryLog +public sealed partial class SentryLog { [SetsRequiredMembers] internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel level, string message) @@ -27,6 +27,20 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le Parameters = ImmutableArray>.Empty; } + internal static SentryLog Create(IHub hub, DateTimeOffset timestamp, SentryLogLevel level, string message, string? template, ImmutableArray> parameters) + { + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + SentryLog log = new(timestamp, traceId, level, message) + { + Template = template, + Parameters = parameters, + SpanId = spanId, + }; + + return log; + } + /// /// The timestamp of the log. /// diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index a6e31c57af..fcc557ec75 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -63,6 +63,9 @@ internal static IHub InitHub(SentryOptions options) #pragma warning restore 0162 #pragma warning restore CS0162 // Unreachable code detected + // This happens before the native SDKs get initialized + options.Environment = options.SettingLocator.GetEnvironment(); + // Initialize native platform SDKs here if (options.InitNativeSdks) { @@ -703,6 +706,23 @@ public static ITransactionTracer StartTransaction(string name, string operation, public static ITransactionTracer StartTransaction(string name, string operation, SentryTraceHeader traceHeader) => CurrentHub.StartTransaction(name, operation, traceHeader); + /// + /// Records a transaction that has already completed elsewhere — for example, spans measured on another + /// machine or process and replayed through a proxy. See + /// . + /// + [DebuggerStepThrough] + public static SentryId RecordTransaction( + string name, + string operation, + DateTimeOffset startTimestamp, + TimeSpan duration, + SentryId? traceId = null, + SpanId? spanId = null, + SpanId? parentSpanId = null, + Action? configure = null) + => CurrentHub.RecordTransaction(name, operation, startTimestamp, duration, traceId, spanId, parentSpanId, configure); + /// /// Binds specified exception the specified span. /// diff --git a/src/Sentry/ViewHierarchyAttachment.cs b/src/Sentry/ViewHierarchyAttachment.cs index d71e9e8db5..658ea52305 100644 --- a/src/Sentry/ViewHierarchyAttachment.cs +++ b/src/Sentry/ViewHierarchyAttachment.cs @@ -8,8 +8,17 @@ public class ViewHierarchyAttachment : SentryAttachment /// /// Initializes an instance of . /// - /// /// The view hierarchy attachment - public ViewHierarchyAttachment(IAttachmentContent content) : - base(AttachmentType.ViewHierarchy, content, "view-hierarchy.json", "application/json") + /// The view hierarchy attachment + public ViewHierarchyAttachment(IAttachmentContent content) + : this(content, false) + { } + + /// + /// Initializes an instance of . + /// + /// The view hierarchy attachment + /// Whether the attachment should be added to transactions. + public ViewHierarchyAttachment(IAttachmentContent content, bool addToTransactions) + : base(AttachmentType.ViewHierarchy, content, "view-hierarchy.json", "application/json", addToTransactions) { } } diff --git a/test/.editorconfig b/test/.editorconfig new file mode 100644 index 0000000000..a6b8a43172 --- /dev/null +++ b/test/.editorconfig @@ -0,0 +1,14 @@ +# Settings that apply only to test code under /test +# (kept here so diagnostic suppressions for tests are easy to find). + +[*.cs] + +# Disable AOT analyser for test files +dotnet_diagnostic.IL2026.severity = none +dotnet_diagnostic.IL2070.severity = none +dotnet_diagnostic.IL2075.severity = none +dotnet_diagnostic.IL2090.severity = none +dotnet_diagnostic.CA2255.severity = none + +# This appears to be broken and results in false positives (causing dotnet format to delete valid test scenarios) +dotnet_diagnostic.xUnit1025.severity = none diff --git a/test/CommonModuleInit.cs b/test/CommonModuleInit.cs index 03c33d63c8..6b11271b6c 100644 --- a/test/CommonModuleInit.cs +++ b/test/CommonModuleInit.cs @@ -2,7 +2,6 @@ public static class CommonModuleInit { [ModuleInitializer] - [SuppressMessage("Usage", "CA2255:The \'ModuleInitializer\' attribute should not be used in libraries")] public static void Init() { VerifyDiffPlex.Initialize(); diff --git a/test/Sentry.AspNetCore.Tests/MiddlewareLoggerIntegration.cs b/test/Sentry.AspNetCore.Tests/MiddlewareLoggerIntegration.cs index d3567c6164..d869be9ff9 100644 --- a/test/Sentry.AspNetCore.Tests/MiddlewareLoggerIntegration.cs +++ b/test/Sentry.AspNetCore.Tests/MiddlewareLoggerIntegration.cs @@ -61,7 +61,8 @@ public SentryMiddleware GetSut() MiddlewareLogger, EventExceptionProcessors, EventProcessors, - TransactionProcessors); + TransactionProcessors, + Substitute.For()); public void Dispose() => _disposable.Dispose(); } diff --git a/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs b/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs index 3f53589e67..9cf3c0158e 100644 --- a/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs +++ b/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Sentry.Ben.BlockingDetector; #if NETCOREAPP3_1_OR_GREATER using IHostingEnvironment = Microsoft.AspNetCore.Hosting.IWebHostEnvironment; @@ -27,6 +29,7 @@ private class Fixture public IEnumerable TransactionProcessors { get; set; } = Substitute.For>(); public IFeatureCollection FeatureCollection { get; set; } = Substitute.For(); public Scope Scope { get; set; } + public IServiceProvider ServiceProvider { get; } public Fixture() { @@ -40,6 +43,15 @@ public Fixture() _ = Hub.IsEnabled.Returns(true); _ = Hub.StartTransaction("", "").ReturnsForAnyArgs(new TransactionTracer(Hub, "test", "test")); _ = HttpContext.Features.Returns(FeatureCollection); + + // Mirrors the singleton registrations in SentryWebHostBuilderExtensions so the + // (transient) middleware can resolve the shared blocking monitor/listener. + ServiceProvider = new ServiceCollection() + .AddSingleton(HubAccessor) + .AddSingleton(Options) + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); } public SentryMiddleware GetSut() @@ -50,11 +62,34 @@ public SentryMiddleware GetSut() Logger, EventExceptionProcessors, EventProcessors, - TransactionProcessors); + TransactionProcessors, + ServiceProvider); } private readonly Fixture _fixture = new(); + [Fact] + public void Constructor_CaptureBlockingCalls_SharesSingleListenerAcrossInstances() + { + _fixture.Options.CaptureBlockingCalls = true; + + // The middleware is registered as transient, so DI creates a new instance per request. + // Simulate two requests: distinct instances must share the one process-wide monitor and + // listener rather than each allocating (and leaking) their own EventListener. See #5378. + var first = _fixture.GetSut(); + var second = _fixture.GetSut(); + + Assert.NotSame(first, second); + Assert.NotNull(first.Listener); + Assert.NotNull(first.Monitor); + Assert.Same(first.Listener, second.Listener); + Assert.Same(first.Monitor, second.Monitor); + + // Dispose the shared listener so it doesn't remain registered as a global EventListener + // (and keep TplEventSource enabled) for the rest of the test run. + (_fixture.ServiceProvider as IDisposable)?.Dispose(); + } + [Fact] public async Task InvokeAsync_DisabledSdk_InvokesNextHandlers() { diff --git a/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj b/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj index afce541b0e..e0217d4ab3 100644 --- a/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj +++ b/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj @@ -19,4 +19,10 @@ + + + SentryAppenderTests.cs + + + diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs new file mode 100644 index 0000000000..8049b6e8fd --- /dev/null +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs @@ -0,0 +1,291 @@ +#nullable enable + +using log4net.Util; +using Sentry.Testing; + +namespace Sentry.Log4Net.Tests; + +public partial class SentryAppenderTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DoAppend_StructuredLogging_IsEnabled(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = isEnabled; + + var sut = _fixture.GetSut(); + + sut.DoAppend(CreateLoggingEvent(Level.Info, "Message")); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + public static TheoryData LogLevelData => new() + { + { Level.All, SentryLogLevel.Trace }, + { Level.Finest, SentryLogLevel.Trace }, + { Level.Verbose, SentryLogLevel.Trace }, + { Level.Finer, SentryLogLevel.Trace }, + { Level.Trace, SentryLogLevel.Trace }, + { Level.Fine, SentryLogLevel.Debug }, + { Level.Debug, SentryLogLevel.Debug }, + { Level.Info, SentryLogLevel.Info }, + { Level.Notice, SentryLogLevel.Info }, + { Level.Warn, SentryLogLevel.Warning }, + { Level.Error, SentryLogLevel.Error }, + { Level.Severe, SentryLogLevel.Error }, + { Level.Critical, SentryLogLevel.Error }, + { Level.Alert, SentryLogLevel.Error }, + { Level.Fatal, SentryLogLevel.Fatal }, + { Level.Emergency, SentryLogLevel.Fatal }, + { Level.Log4Net_Debug, SentryLogLevel.Fatal }, + { new Level(0, "DEFAULT"), SentryLogLevel.Trace }, + { new Level(-1, "CUSTOM"), SentryLogLevel.Trace }, + }; + + [Theory] + [MemberData(nameof(LogLevelData))] + public void DoAppend_StructuredLogging_LogLevel(Level level, SentryLogLevel expected) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + sut.DoAppend(CreateLoggingEvent(level, "Message")); + + capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + _fixture.Options.Environment = "test-environment"; + _fixture.Options.Release = "test-release"; + + if (withActiveSpan) + { + var span = Substitute.For(); + span.TraceId.Returns(SentryId.Create()); + span.SpanId.Returns(SpanId.Create()); + _fixture.Hub.GetSpan().Returns(span); + } + else + { + _fixture.Hub.GetSpan().Returns((ISpan?)null); + } + + var sut = _fixture.GetSut(); + ThreadContext.Properties["Text-Property"] = "4"; + ThreadContext.Properties["Number-Property"] = 4; + ThreadContext.Properties["Collection-Property"] = new[] { 3, 4, 5 }; + ThreadContext.Properties["Object-Property"] = (Number: 4, Text: "4"); + + sut.DoAppend(CreateLoggingEvent(Level.Info, "{0}, {1}, {2}", [0, 1, 2])); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Timestamp.Should().BeOnOrBefore(DateTimeOffset.Now); + log.TraceId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.TraceId : _fixture.Scope.PropagationContext.TraceId); + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("0, 1, 2"); + log.Template.Should().BeNull(); + log.Parameters.Should().BeEmpty(); + log.SpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : null); + + log.Attributes.ShouldContain("sentry.environment", "test-environment"); + log.Attributes.ShouldContain("sentry.release", "test-release"); + log.Attributes.ShouldContain("sentry.origin", "auto.log.log4net"); + log.Attributes.ShouldContain("sentry.sdk.name", SentryAppender.SdkName); + log.Attributes.ShouldContain("sentry.sdk.version", SentryAppender.NameAndVersion.Version); + log.Attributes.ShouldContain("category.name", "TestLogger"); + + log.Attributes.ShouldContain("property.Text-Property", "4"); + log.Attributes.ShouldContain("property.Number-Property", 4); + // Collections are compared by value, so this one keeps BeEquivalentTo rather than the ShouldContain (Be) extension. + log.TryGetAttribute("property.Collection-Property", out object? collection).Should().BeTrue(); + collection.Should().BeEquivalentTo(new[] { 3, 4, 5 }); + log.Attributes.ShouldContain("property.Object-Property", (Number: 4, Text: "4")); + } + + [Fact] + public void DoAppend_StructuredLogging_Properties() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + LoggingEventData data = new() + { + LoggerName = "TestLogger", + Level = Level.Info, + Message = "Test Message", + ThreadName = "1", + LocationInfo = new LocationInfo(null), + UserName = "TestUser", + Identity = "TestIdentity", + ExceptionString = "Exception", + Domain = "TestDomain", + Properties = new PropertiesDictionary(), + TimeStampUtc = DateTime.UtcNow, + }; + data.Properties[""] = "empty"; + data.Properties["test.property.key"] = "test-property-value"; + LoggingEvent loggingEvent = new(data); + sut.DoAppend(loggingEvent); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("Test Message"); + log.Attributes.Should().NotContain(attribute => attribute.Key.Contains("log4net:")); + log.Attributes.Should().ContainSingle(attribute => attribute.Key.StartsWith("property.")); + log.Attributes.ShouldContain("property.test.property.key", "test-property-value"); + } + + [Fact] + public void DoAppend_StructuredLogging_DefaultProperties() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + LoggingEventData data = new() + { + LoggerName = "TestLogger", + Level = Level.Info, + Message = "Test Message", + ThreadName = "1", + LocationInfo = new LocationInfo(null), + UserName = "TestUser", + Identity = "TestIdentity", + ExceptionString = "Exception", + Domain = "TestDomain", + TimeStampUtc = DateTime.UtcNow, + }; + LoggingEvent loggingEvent = new(data); + sut.DoAppend(loggingEvent); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("Test Message"); + log.Attributes.Should().NotContain(attribute => attribute.Key.Contains("log4net:")); + log.Attributes.Should().NotContain(attribute => attribute.Key.StartsWith("property.")); + } + + [Fact] + public void DoAppend_StructuredLoggingWithException_NoBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.MinimumEventLevel = Level.Error; + + sut.DoAppend(CreateLoggingEvent(Level.Error, "Message", new Exception("expected message"))); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + _ = _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void DoAppend_StructuredLoggingWithoutException_LeavesBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.MinimumEventLevel = Level.Fatal; + + sut.DoAppend(CreateLoggingEvent(Level.Error, "Message")); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _ = _fixture.Hub.Received(0).CaptureEvent(Arg.Any()); + } + + [Fact] + public void DoAppend_StructuredLogging_ConfiguredEnvironment_OverridesOptions() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + _fixture.Options.Environment = "options-environment"; + + var sut = _fixture.GetSut(); + sut.Environment = "appender-environment"; + + sut.DoAppend(CreateLoggingEvent(Level.Info, "Message")); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Attributes.ShouldContain("sentry.environment", "appender-environment"); + } + + [Fact] + public void DoAppend_StructuredLogging_SendIdentity_SetsUser() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.SendIdentity = true; + + sut.DoAppend(new LoggingEvent(new LoggingEventData + { + Level = Level.Info, + Message = "Message", + Identity = "TestIdentity", + TimeStampUtc = DateTime.UtcNow, + })); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Attributes.ShouldContain("user.id", "TestIdentity"); + } + + [Fact] + public void DoAppend_StructuredLogging_SendIdentityDisabled_DoesNotSetUser() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + sut.DoAppend(new LoggingEvent(new LoggingEventData + { + Level = Level.Info, + Message = "Message", + Identity = "TestIdentity", + TimeStampUtc = DateTime.UtcNow, + })); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Attributes.ShouldNotContain("user.id"); + } + + private static LoggingEvent CreateLoggingEvent(Level level, string message, Exception? exception = null) + { + return new LoggingEvent(null, null, "TestLogger", level, message, exception); + } + + private static LoggingEvent CreateLoggingEvent(Level level, string format, object[] args) + { + var message = new SystemStringFormat(CultureInfo.InvariantCulture, format, args); + return new LoggingEvent(null, null, "TestLogger", level, message, null); + } +} diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs index 03cd2501a3..cfcad5c7c9 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs @@ -1,6 +1,6 @@ namespace Sentry.Log4Net.Tests; -public class SentryAppenderTests +public partial class SentryAppenderTests : IDisposable { private class Fixture { @@ -12,6 +12,7 @@ private class Fixture public Func HubAccessor { get; set; } public Scope Scope { get; } = new(new SentryOptions()); public string Dsn { get; set; } = "dsn"; + public SentryOptions Options { get; } = new(); public Fixture() { @@ -27,6 +28,8 @@ public Fixture() public SentryAppender GetSut() { + SentryClientExtensions.SentryOptionsForTestingOnly = Options; + var sut = new SentryAppender(InitAction, Hub) { Dsn = Dsn @@ -38,6 +41,13 @@ public SentryAppender GetSut() private readonly Fixture _fixture = new(); + public void Dispose() + { + SentryClientExtensions.SentryOptionsForTestingOnly = null; + // ThreadContext.Properties is thread-static and can leak into subsequent tests running on the same thread. + ThreadContext.Properties.Clear(); + } + [Fact] public void Append_WithException_CreatesEventWithException() { diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj b/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj index d7f32032ed..871bc9b0b5 100644 --- a/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj +++ b/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj @@ -21,4 +21,10 @@ + + + SentryTargetTests.cs + + + diff --git a/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs b/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs new file mode 100644 index 0000000000..8ef9c778bc --- /dev/null +++ b/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs @@ -0,0 +1,210 @@ +#nullable enable + +namespace Sentry.NLog.Tests; + +public partial class SentryTargetTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Write_StructuredLogging_UseHubOptionsOverTargetOptions(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + if (!isEnabled) + { + SentryClientExtensions.SentryOptionsForTestingOnly = null; + } + + var logger = _fixture.GetLogger(); + + logger.Info("Message"); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + public static TheoryData SentryLogLevelData => new() + { + { LogLevel.Trace, SentryLogLevel.Trace }, + { LogLevel.Debug, SentryLogLevel.Debug }, + { LogLevel.Info, SentryLogLevel.Info }, + { LogLevel.Warn, SentryLogLevel.Warning }, + { LogLevel.Error, SentryLogLevel.Error }, + { LogLevel.Fatal, SentryLogLevel.Fatal }, + }; + + [Theory] + [MemberData(nameof(SentryLogLevelData))] + public void Write_StructuredLogging_LogLevel(LogLevel level, SentryLogLevel expected) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Log(level, "Message"); + + capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Write_StructuredLogging_LogEvent(bool withActiveSpan) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + _fixture.Options.Environment = "test-environment"; + _fixture.Options.Release = "test-release"; + + if (withActiveSpan) + { + var span = Substitute.For(); + span.TraceId.Returns(SentryId.Create()); + span.SpanId.Returns(SpanId.Create()); + _fixture.Hub.GetSpan().Returns(span); + } + else + { + _fixture.Hub.GetSpan().Returns((ISpan?)null); + } + + var logger = _fixture.GetLogger() + .WithProperty("Text-Property-Key", "Text-Property-Value") + .WithProperty("Number-Property", 42) + .WithProperty("Collection-Property", new[] { 41, 42, 43 }) + .WithProperty("Map-Property", new Dictionary { { "key", "value" } }) + .WithProperty("Object-Property", (Number: 42, Text: "42")); + + logger.Info("{}, {Text}, {Number}, {Collection}, {Map}, {Object}.", + null, "Text", 42, new[] { 41, 42, 43 }, new Dictionary { { "key", "value" } }, (Number: 42, Text: "42")); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Timestamp.Should().BeOnOrBefore(DateTimeOffset.Now); + log.TraceId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.TraceId : _fixture.Scope.PropagationContext.TraceId); + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("""NULL, "Text", 42, 41, 42, 43, "key"="value", (42, 42)."""); + log.Template.Should().Be("{}, {Text}, {Number}, {Collection}, {Map}, {Object}."); + log.Parameters.Should().HaveCount(6); + log.Parameters[0].Should().BeEquivalentTo(new KeyValuePair("0", null)); + log.Parameters[1].Should().BeEquivalentTo(new KeyValuePair("Text", "Text")); + log.Parameters[2].Should().BeEquivalentTo(new KeyValuePair("Number", 42)); + log.Parameters[3].Should().BeEquivalentTo(new KeyValuePair("Collection", new[] { 41, 42, 43 })); + log.Parameters[4].Should().BeEquivalentTo(new KeyValuePair("Map", new Dictionary { { "key", "value" } })); + log.Parameters[5].Should().BeEquivalentTo(new KeyValuePair("Object", (Number: 42, Text: "42"))); + log.SpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : null); + + log.Attributes.ShouldContain("sentry.environment", "test-environment"); + log.Attributes.ShouldContain("sentry.release", "test-release"); + log.Attributes.ShouldContain("sentry.origin", "auto.log.nlog"); + log.Attributes.ShouldContain("sentry.sdk.name", Constants.SdkName); + log.Attributes.ShouldContain("sentry.sdk.version", SentryTarget.NameAndVersion.Version); + log.Attributes.ShouldContain("category.name", "sentry"); + + log.Attributes.ShouldContain("property.Text-Property-Key", "Text-Property-Value"); + log.Attributes.ShouldContain("property.Number-Property", 42); + log.Attributes.TryGetAttribute("property.Collection-Property", out int[]? collection).Should().BeTrue(); + collection.Should().BeEquivalentTo(new[] { 41, 42, 43 }); + log.Attributes.TryGetAttribute("property.Map-Property", out Dictionary? map).Should().BeTrue(); + map.Should().BeEquivalentTo(new Dictionary { { "key", "value" } }); + log.Attributes.ShouldContain("property.Object-Property", (Number: 42, Text: "42")); + + log.Attributes.ShouldNotContain("property.Text"); + log.Attributes.ShouldNotContain("property.Number"); + log.Attributes.ShouldNotContain("property.Collection"); + log.Attributes.ShouldNotContain("property.Map"); + log.Attributes.ShouldNotContain("property.Object"); + } + + [Fact] + public void Write_StructuredLogging_IsPositional() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Info("{0}, {1}, {2}, {3}.", 0, 1, 2, 3); + + capturer.Logs.Should().ContainSingle().Which.Parameters.Should().BeEquivalentTo([ + new KeyValuePair("0", 0), + new KeyValuePair("1", 1), + new KeyValuePair("2", 2), + new KeyValuePair("3", 3), + ]); + } + + [Fact] + public void Write_StructuredLogging_UnnamedHolesDoNotCollide() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Info("{}, {}, {}.", "first", "second", "third"); + + // Unnamed holes must each keep their value: no empty parameter names (which would serialize to the + // same `sentry.message.parameter.` attribute key) and no collisions. + var parameters = capturer.Logs.Should().ContainSingle().Which.Parameters; + parameters.Select(parameter => parameter.Value).Should().Equal("first", "second", "third"); + parameters.Should().OnlyContain(parameter => !string.IsNullOrEmpty(parameter.Key)); + parameters.Select(parameter => parameter.Key).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void Write_StructuredLoggingWithException_NoBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Error(new Exception("expected message"), "Message"); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void Write_StructuredLoggingWithoutException_LeavesBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Error((Exception?)null, "Message"); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void Write_StructuredLoggingThrows_DoesNotBlockEventOrPropagate() + { + // Force the structured-log capture to fail. + _fixture.Hub.Logger.Returns(_ => throw new InvalidOperationException("structured logging failed")); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + // Surface any exception that escapes the target, so an unguarded failure would fail this test. + logger.Factory.ThrowExceptions = true; + + // Must not throw, even though the structured-log capture fails. + logger.Error(new Exception("expected message"), "Message"); + + // The higher-priority event must still be captured. + _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } +} diff --git a/test/Sentry.NLog.Tests/SentryTargetTests.cs b/test/Sentry.NLog.Tests/SentryTargetTests.cs index 30fae65e90..2bb97d6e7a 100644 --- a/test/Sentry.NLog.Tests/SentryTargetTests.cs +++ b/test/Sentry.NLog.Tests/SentryTargetTests.cs @@ -2,7 +2,7 @@ namespace Sentry.NLog.Tests; -public class SentryTargetTests +public partial class SentryTargetTests { private const string DefaultMessage = "This is a logged message"; @@ -24,6 +24,7 @@ public Fixture() HubAccessor = () => Hub; Scope = new Scope(new SentryOptions()); Hub.SubstituteConfigureScope(Scope); + SentryClientExtensions.SentryOptionsForTestingOnly = Options; } public Target GetTarget(bool asyncTarget = false) @@ -592,6 +593,30 @@ public void MinimumBreadcrumbLevel_SetterReplacesOptions() Assert.Equal(expected, target.MinimumBreadcrumbLevel); } + [Fact] + public void EnableLogs_Default_False() + { + var target = (SentryTarget)_fixture.GetTarget(); + Assert.False(target.EnableLogs); + } + + [Fact] + public void EnableLogs_SetInOptions_ReturnsValue() + { + _fixture.Options.EnableLogs = true; + var target = (SentryTarget)_fixture.GetTarget(); + Assert.True(target.EnableLogs); + } + + [Fact] + public void EnableLogs_SetterReplacesOptions() + { + _fixture.Options.EnableLogs = false; + var target = (SentryTarget)_fixture.GetTarget(); + target.EnableLogs = true; + Assert.True(target.EnableLogs); + } + [Fact] public void SendEventPropertiesAsData_Default_True() { diff --git a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs index 1c3adbbea5..e008efee3a 100644 --- a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs +++ b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs @@ -965,6 +965,25 @@ public void PruneFilteredSpans_GarbageCollectedActivity_Pruned() Assert.False(sut._map.TryGetValue(spanId, out _)); } + [Fact] + public void OnStart_FusesActivityWeakly() + { + // Arrange + _fixture.Options.Instrumenter = Instrumenter.OpenTelemetry; + var sut = _fixture.GetSut(); + + using var activity = Tracer.StartActivity("test")!; + + // Act + sut.OnStart(activity); + + // The Activity must be fused via a WeakReference, not strongly. A strong reference is pinned by + // _map, which prevents GC and defeats PruneFilteredSpans, leaking never-ended spans. + sut._map.TryGetValue(activity.SpanId, out var span).Should().BeTrue(); + span.GetFused>("Activity").Should().NotBeNull(); + span.GetFused("Activity").Should().BeNull("the Activity must not be fused with a strong reference"); + } + [Fact] public void PruneFilteredSpans_RecentlyPruned_DoesNothing() { diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index bb2d777e40..6374c53e3e 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -11,8 +11,10 @@ namespace Sentry.Serilog public SentrySerilogOptions() { } public System.IFormatProvider? FormatProvider { get; set; } public bool InitializeSdk { get; set; } + public Serilog.Core.LoggingLevelSwitch? LevelSwitch { get; set; } public Serilog.Events.LogEventLevel MinimumBreadcrumbLevel { get; set; } public Serilog.Events.LogEventLevel MinimumEventLevel { get; set; } + public Serilog.Events.LogEventLevel RestrictedToMinimumLevel { get; set; } public Serilog.Formatting.ITextFormatter? TextFormatter { get; set; } } } @@ -21,7 +23,7 @@ namespace Serilog public static class SentrySinkExtensions { public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, System.Action configureOptions) { } - public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null) { } + public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null, Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } public static Serilog.LoggerConfiguration Sentry( this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, string dsn, @@ -47,6 +49,8 @@ namespace Serilog Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, System.Collections.Generic.Dictionary? defaultTags = null, - bool? enableLogs = default) { } + bool? enableLogs = default, + Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, + Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index bb2d777e40..6374c53e3e 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -11,8 +11,10 @@ namespace Sentry.Serilog public SentrySerilogOptions() { } public System.IFormatProvider? FormatProvider { get; set; } public bool InitializeSdk { get; set; } + public Serilog.Core.LoggingLevelSwitch? LevelSwitch { get; set; } public Serilog.Events.LogEventLevel MinimumBreadcrumbLevel { get; set; } public Serilog.Events.LogEventLevel MinimumEventLevel { get; set; } + public Serilog.Events.LogEventLevel RestrictedToMinimumLevel { get; set; } public Serilog.Formatting.ITextFormatter? TextFormatter { get; set; } } } @@ -21,7 +23,7 @@ namespace Serilog public static class SentrySinkExtensions { public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, System.Action configureOptions) { } - public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null) { } + public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null, Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } public static Serilog.LoggerConfiguration Sentry( this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, string dsn, @@ -47,6 +49,8 @@ namespace Serilog Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, System.Collections.Generic.Dictionary? defaultTags = null, - bool? enableLogs = default) { } + bool? enableLogs = default, + Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, + Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index bb2d777e40..6374c53e3e 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -11,8 +11,10 @@ namespace Sentry.Serilog public SentrySerilogOptions() { } public System.IFormatProvider? FormatProvider { get; set; } public bool InitializeSdk { get; set; } + public Serilog.Core.LoggingLevelSwitch? LevelSwitch { get; set; } public Serilog.Events.LogEventLevel MinimumBreadcrumbLevel { get; set; } public Serilog.Events.LogEventLevel MinimumEventLevel { get; set; } + public Serilog.Events.LogEventLevel RestrictedToMinimumLevel { get; set; } public Serilog.Formatting.ITextFormatter? TextFormatter { get; set; } } } @@ -21,7 +23,7 @@ namespace Serilog public static class SentrySinkExtensions { public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, System.Action configureOptions) { } - public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null) { } + public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null, Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } public static Serilog.LoggerConfiguration Sentry( this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, string dsn, @@ -47,6 +49,8 @@ namespace Serilog Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, System.Collections.Generic.Dictionary? defaultTags = null, - bool? enableLogs = default) { } + bool? enableLogs = default, + Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, + Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index bb2d777e40..6374c53e3e 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -11,8 +11,10 @@ namespace Sentry.Serilog public SentrySerilogOptions() { } public System.IFormatProvider? FormatProvider { get; set; } public bool InitializeSdk { get; set; } + public Serilog.Core.LoggingLevelSwitch? LevelSwitch { get; set; } public Serilog.Events.LogEventLevel MinimumBreadcrumbLevel { get; set; } public Serilog.Events.LogEventLevel MinimumEventLevel { get; set; } + public Serilog.Events.LogEventLevel RestrictedToMinimumLevel { get; set; } public Serilog.Formatting.ITextFormatter? TextFormatter { get; set; } } } @@ -21,7 +23,7 @@ namespace Serilog public static class SentrySinkExtensions { public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, System.Action configureOptions) { } - public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null) { } + public static Serilog.LoggerConfiguration Sentry(this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, Serilog.Events.LogEventLevel? minimumEventLevel = default, Serilog.Events.LogEventLevel? minimumBreadcrumbLevel = default, System.IFormatProvider? formatProvider = null, Serilog.Formatting.ITextFormatter? textFormatter = null, Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } public static Serilog.LoggerConfiguration Sentry( this Serilog.Configuration.LoggerSinkConfiguration loggerConfiguration, string dsn, @@ -47,6 +49,8 @@ namespace Serilog Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, System.Collections.Generic.Dictionary? defaultTags = null, - bool? enableLogs = default) { } + bool? enableLogs = default, + Serilog.Events.LogEventLevel restrictedToMinimumLevel = 0, + Serilog.Core.LoggingLevelSwitch? levelSwitch = null) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj b/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj index e89dfe2272..ec8bc74fbc 100644 --- a/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj +++ b/test/Sentry.Serilog.Tests/Sentry.Serilog.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs b/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs index 36c3f1f802..d146bb87ed 100644 --- a/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs +++ b/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs @@ -29,6 +29,8 @@ private class Fixture public bool InitializeSdk { get; } = false; public LogEventLevel MinimumEventLevel { get; } = LogEventLevel.Verbose; public LogEventLevel MinimumBreadcrumbLevel { get; } = LogEventLevel.Fatal; + public LogEventLevel RestrictedToMinimumLevel { get; } = LogEventLevel.Warning; + public LoggingLevelSwitch LevelSwitch { get; } = new(LogEventLevel.Error); public static SentrySerilogOptions GetSut() => new(); } @@ -98,7 +100,8 @@ public void ConfigureSentrySerilogOptions_WithAllParameters_MakesAppropriateChan _fixture.SampleRate, _fixture.Release, _fixture.Environment, _fixture.MaxQueueItems, _fixture.ShutdownTimeout, _fixture.DecompressionMethods, _fixture.RequestBodyCompressionLevel, _fixture.RequestBodyCompressionBuffered, _fixture.Debug, _fixture.DiagnosticLevel, - _fixture.ReportAssembliesMode, _fixture.DeduplicateMode, null, _fixture.EnableLogs); + _fixture.ReportAssembliesMode, _fixture.DeduplicateMode, null, _fixture.EnableLogs, + _fixture.RestrictedToMinimumLevel, _fixture.LevelSwitch); // Compare individual properties Assert.Equal(_fixture.SendDefaultPii, sut.SendDefaultPii); @@ -123,6 +126,52 @@ public void ConfigureSentrySerilogOptions_WithAllParameters_MakesAppropriateChan Assert.True(sut.InitializeSdk); Assert.Equal(_fixture.MinimumEventLevel, sut.MinimumEventLevel); Assert.Equal(_fixture.MinimumBreadcrumbLevel, sut.MinimumBreadcrumbLevel); + Assert.Equal(_fixture.RestrictedToMinimumLevel, sut.RestrictedToMinimumLevel); + Assert.Same(_fixture.LevelSwitch, sut.LevelSwitch); + } + + [Fact] + public void Sentry_WithRestrictedToMinimumLevel_ConfigureOptions_FiltersLogsBelow() + { + // Arrange + var hub = Substitute.For(); + hub.IsEnabled.Returns(true); + var options = new SentrySerilogOptions + { + InitializeSdk = false, + MinimumBreadcrumbLevel = LogEventLevel.Verbose, + MinimumEventLevel = LogEventLevel.Verbose, + RestrictedToMinimumLevel = LogEventLevel.Error, + }; + var sink = new SentrySink(options, () => hub, null, new MockClock()); + using var logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Sink(sink, options.RestrictedToMinimumLevel, options.LevelSwitch) + .CreateLogger(); + + // Act + logger.Warning("Below threshold"); + logger.Error("At threshold"); + + // Assert: Warning is filtered by Serilog before reaching the sink; only Error gets through + hub.Received(1).CaptureEvent(Arg.Any()); + hub.DidNotReceive().CaptureEvent(Arg.Is(e => + e.Message.Message == "Below threshold")); + } + + [Fact] + public void Sentry_WithRestrictedToMinimumLevel_NoDsn_ParameterIsAccepted() + { + // Verify the no-DSN overload accepts restrictedToMinimumLevel without throwing + var ex = Record.Exception(() => + new LoggerConfiguration() + .WriteTo.Sentry( + minimumBreadcrumbLevel: LogEventLevel.Verbose, + minimumEventLevel: LogEventLevel.Error, + restrictedToMinimumLevel: LogEventLevel.Warning) + .CreateLogger()); + + Assert.Null(ex); } private static void AssertEqualDeep(object expected, object actual) diff --git a/test/Sentry.Testing/Sentry.Testing.csproj b/test/Sentry.Testing/Sentry.Testing.csproj index a1576ba0a5..f247c00a3d 100644 --- a/test/Sentry.Testing/Sentry.Testing.csproj +++ b/test/Sentry.Testing/Sentry.Testing.csproj @@ -16,7 +16,9 @@ + + diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 35d2d30f47..b9fb58dba4 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -152,6 +152,7 @@ namespace Sentry public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { } + public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { } @@ -221,6 +222,7 @@ namespace Sentry void AddAttachment(Sentry.SentryAttachment attachment); void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); void ClearAttachments(); + void SetEnvironment(string? environment); void SetExtra(string key, object? value); void SetTag(string key, string value); void SetTrace(Sentry.SentryId traceId, Sentry.SpanId parentSpanId); @@ -294,6 +296,15 @@ namespace Sentry Sentry.SentryTraceHeader GetTraceHeader(); void SetMeasurement(string name, Sentry.Protocol.Measurement measurement); } + public interface ISpanRecorder + { + string? Description { get; set; } + Sentry.SpanId SpanId { get; } + Sentry.SpanStatus? Status { get; set; } + Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null); + void SetData(string key, object? value); + void SetTag(string key, string value); + } public interface ITransactionContext : Sentry.Protocol.ITraceContext { bool? IsParentSampled { get; } @@ -304,6 +315,13 @@ namespace Sentry { string? Platform { get; set; } } + public interface ITransactionRecorder : Sentry.ISpanRecorder + { + string? Environment { get; set; } + string? Release { get; set; } + Sentry.SentryId TraceId { get; } + void ConfigureScope(System.Action configureScope); + } public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable { new bool? IsParentSampled { get; set; } @@ -438,6 +456,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -995,6 +1015,7 @@ namespace Sentry public static void PauseSession() { } public static System.IDisposable PushScope() { } public static System.IDisposable PushScope(TState state) { } + public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static void ResumeSession() { } public static void SetTag(string key, string value) { } public static void StartSession() { } @@ -1416,6 +1437,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -2018,7 +2040,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 35d2d30f47..b9fb58dba4 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -152,6 +152,7 @@ namespace Sentry public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { } + public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { } @@ -221,6 +222,7 @@ namespace Sentry void AddAttachment(Sentry.SentryAttachment attachment); void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); void ClearAttachments(); + void SetEnvironment(string? environment); void SetExtra(string key, object? value); void SetTag(string key, string value); void SetTrace(Sentry.SentryId traceId, Sentry.SpanId parentSpanId); @@ -294,6 +296,15 @@ namespace Sentry Sentry.SentryTraceHeader GetTraceHeader(); void SetMeasurement(string name, Sentry.Protocol.Measurement measurement); } + public interface ISpanRecorder + { + string? Description { get; set; } + Sentry.SpanId SpanId { get; } + Sentry.SpanStatus? Status { get; set; } + Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null); + void SetData(string key, object? value); + void SetTag(string key, string value); + } public interface ITransactionContext : Sentry.Protocol.ITraceContext { bool? IsParentSampled { get; } @@ -304,6 +315,13 @@ namespace Sentry { string? Platform { get; set; } } + public interface ITransactionRecorder : Sentry.ISpanRecorder + { + string? Environment { get; set; } + string? Release { get; set; } + Sentry.SentryId TraceId { get; } + void ConfigureScope(System.Action configureScope); + } public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable { new bool? IsParentSampled { get; set; } @@ -438,6 +456,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -995,6 +1015,7 @@ namespace Sentry public static void PauseSession() { } public static System.IDisposable PushScope() { } public static System.IDisposable PushScope(TState state) { } + public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static void ResumeSession() { } public static void SetTag(string key, string value) { } public static void StartSession() { } @@ -1416,6 +1437,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -2018,7 +2040,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 35d2d30f47..b9fb58dba4 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -152,6 +152,7 @@ namespace Sentry public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { } + public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { } @@ -221,6 +222,7 @@ namespace Sentry void AddAttachment(Sentry.SentryAttachment attachment); void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); void ClearAttachments(); + void SetEnvironment(string? environment); void SetExtra(string key, object? value); void SetTag(string key, string value); void SetTrace(Sentry.SentryId traceId, Sentry.SpanId parentSpanId); @@ -294,6 +296,15 @@ namespace Sentry Sentry.SentryTraceHeader GetTraceHeader(); void SetMeasurement(string name, Sentry.Protocol.Measurement measurement); } + public interface ISpanRecorder + { + string? Description { get; set; } + Sentry.SpanId SpanId { get; } + Sentry.SpanStatus? Status { get; set; } + Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null); + void SetData(string key, object? value); + void SetTag(string key, string value); + } public interface ITransactionContext : Sentry.Protocol.ITraceContext { bool? IsParentSampled { get; } @@ -304,6 +315,13 @@ namespace Sentry { string? Platform { get; set; } } + public interface ITransactionRecorder : Sentry.ISpanRecorder + { + string? Environment { get; set; } + string? Release { get; set; } + Sentry.SentryId TraceId { get; } + void ConfigureScope(System.Action configureScope); + } public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable { new bool? IsParentSampled { get; set; } @@ -438,6 +456,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -995,6 +1015,7 @@ namespace Sentry public static void PauseSession() { } public static System.IDisposable PushScope() { } public static System.IDisposable PushScope(TState state) { } + public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static void ResumeSession() { } public static void SetTag(string key, string value) { } public static void StartSession() { } @@ -1416,6 +1437,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -2018,7 +2040,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index fe7b7cab37..2e103ee02d 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -140,6 +140,7 @@ namespace Sentry public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { } + public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { } public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { } @@ -209,6 +210,7 @@ namespace Sentry void AddAttachment(Sentry.SentryAttachment attachment); void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); void ClearAttachments(); + void SetEnvironment(string? environment); void SetExtra(string key, object? value); void SetTag(string key, string value); void SetTrace(Sentry.SentryId traceId, Sentry.SpanId parentSpanId); @@ -282,6 +284,15 @@ namespace Sentry Sentry.SentryTraceHeader GetTraceHeader(); void SetMeasurement(string name, Sentry.Protocol.Measurement measurement); } + public interface ISpanRecorder + { + string? Description { get; set; } + Sentry.SpanId SpanId { get; } + Sentry.SpanStatus? Status { get; set; } + Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null); + void SetData(string key, object? value); + void SetTag(string key, string value); + } public interface ITransactionContext : Sentry.Protocol.ITraceContext { bool? IsParentSampled { get; } @@ -292,6 +303,13 @@ namespace Sentry { string? Platform { get; set; } } + public interface ITransactionRecorder : Sentry.ISpanRecorder + { + string? Environment { get; set; } + string? Release { get; set; } + Sentry.SentryId TraceId { get; } + void ConfigureScope(System.Action configureScope); + } public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable { new bool? IsParentSampled { get; set; } @@ -426,6 +444,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -976,6 +996,7 @@ namespace Sentry public static void PauseSession() { } public static System.IDisposable PushScope() { } public static System.IDisposable PushScope(TState state) { } + public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { } public static void ResumeSession() { } public static void SetTag(string key, string value) { } public static void StartSession() { } @@ -1397,6 +1418,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -1994,7 +2016,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/AttachmentHelper.cs b/test/Sentry.Tests/AttachmentHelper.cs index 562313fb2a..daa2e7b640 100644 --- a/test/Sentry.Tests/AttachmentHelper.cs +++ b/test/Sentry.Tests/AttachmentHelper.cs @@ -2,12 +2,13 @@ namespace Sentry.Tests { internal static class AttachmentHelper { - internal static SentryAttachment FakeAttachment(string name = "test.txt") + internal static SentryAttachment FakeAttachment(string name = "test.txt", bool addToTransactions = false) => new( AttachmentType.Default, new StreamAttachmentContent(new MemoryStream(new byte[] { 1 })), name, - null + null, + addToTransactions ); } } diff --git a/test/Sentry.Tests/AttachmentTests.cs b/test/Sentry.Tests/AttachmentTests.cs index 95a3666eca..7e39ba599a 100644 --- a/test/Sentry.Tests/AttachmentTests.cs +++ b/test/Sentry.Tests/AttachmentTests.cs @@ -24,6 +24,28 @@ public void GetStream_ReturnsBytesContent() } } +public class ViewHierarchyAttachmentTests +{ + [Fact] + public void Ctor_DefaultsAddToTransactionsToFalse() + { + var attachment = new ViewHierarchyAttachment(new ByteAttachmentContent(new byte[] { 1 })); + Assert.False(attachment.AddToTransactions); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Ctor_ForwardsAddToTransactions(bool addToTransactions) + { + var attachment = new ViewHierarchyAttachment( + new ByteAttachmentContent(new byte[] { 1 }), + addToTransactions); + + Assert.Equal(addToTransactions, attachment.AddToTransactions); + } +} + public class FileAttachmentContentTests { [Fact] diff --git a/test/Sentry.Tests/HubExtensionsRecordTests.cs b/test/Sentry.Tests/HubExtensionsRecordTests.cs new file mode 100644 index 0000000000..cf06c866e8 --- /dev/null +++ b/test/Sentry.Tests/HubExtensionsRecordTests.cs @@ -0,0 +1,152 @@ +#nullable enable + +namespace Sentry.Tests; + +public class HubExtensionsRecordTests +{ + private readonly IHub _hub = Substitute.For(); + private SentryTransaction? _captured; + + public HubExtensionsRecordTests() + { + // With a substitute hub, GetSentryOptions() returns null, so RecordTransaction falls back to the + // single-argument CaptureTransaction overload. + _hub.When(h => h.CaptureTransaction(Arg.Any())) + .Do(ci => _captured = ci.Arg()); + _hub.When(h => h.CaptureTransaction(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => _captured = ci.Arg()); + } + + [Fact] + public void RecordTransaction_SetsNameOperationAndTiming() + { + var start = new DateTimeOffset(2023, 09, 28, 10, 00, 00, TimeSpan.Zero); + var duration = TimeSpan.FromSeconds(5); + + var eventId = _hub.RecordTransaction("my-transaction", "my-op", start, duration); + + var transaction = Assert.IsType(_captured); + Assert.Equal(eventId, transaction.EventId); + Assert.Equal("my-transaction", transaction.Name); + Assert.Equal("my-op", transaction.Operation); + Assert.Equal(start, transaction.StartTimestamp); + Assert.Equal(start + duration, transaction.EndTimestamp); + Assert.True(transaction.IsFinished); + Assert.True(transaction.IsSampled); + Assert.Equal(SpanStatus.Ok, transaction.Status); + } + + [Fact] + public void RecordTransaction_PreservesTraceAndSpanIds() + { + var traceId = SentryId.Create(); + var spanId = SpanId.Create(); + var parentSpanId = SpanId.Create(); + + _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), + traceId: traceId, spanId: spanId, parentSpanId: parentSpanId); + + var transaction = Assert.IsType(_captured); + Assert.Equal(traceId, transaction.TraceId); + Assert.Equal(spanId, transaction.SpanId); + Assert.Equal(parentSpanId, transaction.ParentSpanId); + } + + [Fact] + public void RecordTransaction_AppliesMetadata() + { + _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), configure: tx => + { + tx.Release = "1.2.3"; + tx.Environment = "staging"; + tx.Description = "recorded elsewhere"; + tx.Status = SpanStatus.InternalError; + tx.SetTag("origin", "proxy"); + tx.SetData("count", 42); + }); + + var transaction = Assert.IsType(_captured); + Assert.Equal("1.2.3", transaction.Release); + Assert.Equal("staging", transaction.Environment); + Assert.Equal("recorded elsewhere", transaction.Description); + Assert.Equal(SpanStatus.InternalError, transaction.Status); + Assert.Equal("proxy", transaction.Tags["origin"]); + Assert.Equal(42, transaction.Data["count"]); + } + + [Fact] + public void RecordTransaction_RecordsNestedSpanTree() + { + var start = new DateTimeOffset(2023, 09, 28, 10, 00, 00, TimeSpan.Zero); + var traceId = SentryId.Create(); + var rootSpanId = SpanId.Create(); + var childSpanId = SpanId.Create(); + var grandchildSpanId = SpanId.Create(); + + _hub.RecordTransaction("t", "root-op", start, TimeSpan.FromSeconds(10), + traceId: traceId, spanId: rootSpanId, configure: tx => + { + tx.RecordSpan("child-op", start.AddSeconds(1), TimeSpan.FromSeconds(2), spanId: childSpanId, configure: child => + { + child.Description = "child"; + child.RecordSpan("grandchild-op", start.AddSeconds(1.5), TimeSpan.FromSeconds(0.5), spanId: grandchildSpanId); + }); + }); + + var transaction = Assert.IsType(_captured); + Assert.Equal(2, transaction.Spans.Count); + + var child = transaction.Spans.Single(s => s.Operation == "child-op"); + Assert.Equal(childSpanId, child.SpanId); + Assert.Equal(rootSpanId, child.ParentSpanId); // structural parent = transaction root + Assert.Equal(traceId, child.TraceId); // trace id inherited + Assert.Equal("child", child.Description); + Assert.Equal(start.AddSeconds(1), child.StartTimestamp); + Assert.Equal(start.AddSeconds(3), child.EndTimestamp); + + var grandchild = transaction.Spans.Single(s => s.Operation == "grandchild-op"); + Assert.Equal(grandchildSpanId, grandchild.SpanId); + Assert.Equal(childSpanId, grandchild.ParentSpanId); // nested under child + Assert.Equal(traceId, grandchild.TraceId); + } + + [Fact] + public void RecordTransaction_ConfigureScope_AppliesToCaptureScope() + { + // With options available, RecordTransaction captures against a fresh scope (via the 3-arg overload) + // that ConfigureScope can mutate — rather than the current live scope. + var previous = SentryClientExtensions.SentryOptionsForTestingOnly; + SentryClientExtensions.SentryOptionsForTestingOnly = new SentryOptions(); + try + { + Scope? capturedScope = null; + _hub.When(h => h.CaptureTransaction(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => capturedScope = ci.Arg()); + + _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), configure: tx => + tx.ConfigureScope(s => s.SetTag("origin", "proxy"))); + + Assert.NotNull(capturedScope); + Assert.Equal("proxy", capturedScope!.Tags["origin"]); + } + finally + { + SentryClientExtensions.SentryOptionsForTestingOnly = previous; + } + } + + [Fact] + public void RecordTransaction_NegativeDuration_Throws() + { + Assert.Throws(() => + _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void RecordSpan_NegativeDuration_Throws() + { + Assert.Throws(() => + _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), configure: tx => + tx.RecordSpan("child", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(-1)))); + } +} diff --git a/test/Sentry.Tests/Internals/BackpressureMonitorTests.cs b/test/Sentry.Tests/Internals/BackpressureMonitorTests.cs index 9cb10a4c3d..42e28cd0fc 100644 --- a/test/Sentry.Tests/Internals/BackpressureMonitorTests.cs +++ b/test/Sentry.Tests/Internals/BackpressureMonitorTests.cs @@ -160,4 +160,86 @@ public void DoHealthCheck_Healthy_DownsampleLevelResets() monitor.IsHealthy.Should().BeTrue(); monitor.DownsampleLevel.Should().Be(0); } + + [Fact] + public async Task Dispose_DoesNotBlockOnWorkerTask() + { + // Arrange + // Run the periodic worker on a scheduler we never pump, so the worker task never completes. This models + // a single-threaded runtime (e.g. Unity WebGL) where the only thread able to run the worker's + // cancellation continuation is the one calling Dispose. A blocking _workerTask.Wait() would deadlock + // there; Dispose must instead just cancel and return. See https://github.com/getsentry/sentry-dotnet/issues/5237 + var scheduler = new NeverRunsTaskScheduler(); + var monitor = new BackpressureMonitor(null, _fixture.Clock, enablePeriodicHealthCheck: true, scheduler: scheduler); + + // Act + var disposed = Task.Run(() => monitor.Dispose()); + + // Assert + var completed = await Task.WhenAny(disposed, Task.Delay(TimeSpan.FromSeconds(10))); + completed.Should().BeSameAs(disposed, "Dispose must not block waiting on the worker task to complete"); + await disposed; // surface any exception thrown by Dispose + } + + [Fact] + public async Task Dispose_WorkerRunsToCompletionWithoutFaulting() + { + // Arrange - a real worker on the thread pool. Dispose cancels the token while the worker may still be + // inside Task.Delay; the CancellationTokenSource must not be disposed out from under it (which would + // surface an unobserved ObjectDisposedException). See https://github.com/getsentry/sentry-dotnet/issues/5237 + _fixture.Clock.GetUtcNow().Returns(_fixture.Now); + var monitor = new BackpressureMonitor(null, _fixture.Clock, enablePeriodicHealthCheck: true); + var worker = monitor.WorkerTask; + + // Act + monitor.Dispose(); + await worker; // observes the task; throws if it faulted + + // Assert + worker.Status.Should().Be(TaskStatus.RanToCompletion); + } + + [Fact] + public void Dispose_CalledMultipleTimes_IsIdempotent() + { + // Arrange + var logger = Substitute.For(); + _fixture.Clock.GetUtcNow().Returns(_fixture.Now); + var monitor = new BackpressureMonitor(logger, _fixture.Clock, enablePeriodicHealthCheck: false); + + // Act + monitor.Dispose(); + monitor.Dispose(); + monitor.Dispose(); + + // Assert - the second and third calls are no-ops; no ObjectDisposedException is logged. + logger.DidNotReceive().Log( + SentryLevel.Warning, Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// + /// A scheduler that queues tasks but never executes them - lets us hold the worker task in a state that + /// never completes, so a Dispose that blocks on it would hang. + /// + private sealed class NeverRunsTaskScheduler : TaskScheduler + { + private readonly List _tasks = new(); + protected override IEnumerable GetScheduledTasks() + { + lock (_tasks) + { + return _tasks.ToArray(); + } + } + + protected override void QueueTask(Task task) + { + lock (_tasks) + { + _tasks.Add(task); + } + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + } } diff --git a/test/Sentry.Tests/Internals/BatchProcessorTests.cs b/test/Sentry.Tests/Internals/BatchProcessorTests.cs index 665097a96a..eedb616577 100644 --- a/test/Sentry.Tests/Internals/BatchProcessorTests.cs +++ b/test/Sentry.Tests/Internals/BatchProcessorTests.cs @@ -159,7 +159,7 @@ public async Task Enqueue_Concurrency_CaptureEnvelopes() if (_fixture.ClientReportRecorder.GenerateClientReport() is { } clientReport) { var discardedEvent = Assert.Single(clientReport.DiscardedEvents); - Assert.Equal(new DiscardReasonWithCategory(DiscardReason.Backpressure, DataCategory.Default), discardedEvent.Key); + Assert.Equal(new DiscardReasonWithCategory(DiscardReason.Backpressure, DataCategory.LogItem), discardedEvent.Key); droppedLogs = discardedEvent.Value; _fixture.ExpectedDiagnosticLogs = discardedEvent.Value; diff --git a/test/Sentry.Tests/Internals/Http/HttpTransportTests.cs b/test/Sentry.Tests/Internals/Http/HttpTransportTests.cs index 179f1f728c..6414ff48e4 100644 --- a/test/Sentry.Tests/Internals/Http/HttpTransportTests.cs +++ b/test/Sentry.Tests/Internals/Http/HttpTransportTests.cs @@ -289,7 +289,7 @@ public async Task SendEnvelopeAsync_ItemRateLimit_DropsItem(string metricNamespa // Arrange using var httpHandler = new RecordingHttpMessageHandler( new FakeHttpMessageHandler( - () => SentryResponses.GetRateLimitResponse($"1234:event, 897:transaction, {metricNamespace}") + () => SentryResponses.GetRateLimitResponse($"1234:error, 897:transaction, {metricNamespace}") )); var httpTransport = new HttpTransport( @@ -369,7 +369,7 @@ public async Task SendEnvelopeAsync_RateLimited_CountsDiscardedEventsCorrectly() // Arrange using var httpHandler = new RecordingHttpMessageHandler( new FakeHttpMessageHandler( - () => SentryResponses.GetRateLimitResponse("1234:event, 897:transaction") + () => SentryResponses.GetRateLimitResponse("1234:error, 897:transaction") )); var options = new SentryOptions @@ -485,7 +485,7 @@ public async Task SendEnvelopeAsync_Fails_RestoresDiscardedEventCounts() // We also expect two new items recorded, due to the forced HTTP failure. {DiscardReason.SendError.WithCategory(DataCategory.Error), 1}, // from the event - {DiscardReason.SendError.WithCategory(DataCategory.Default), 1} // from the client report + {DiscardReason.SendError.WithCategory(DataCategory.Internal), 1} // from the client report }); } @@ -858,7 +858,7 @@ public async Task SendEnvelopeAsync_RateLimited_CallsBackpressureMonitor() // Arrange using var httpHandler = new RecordingHttpMessageHandler( new FakeHttpMessageHandler( - () => SentryResponses.GetRateLimitResponse("1234:event, 897:transaction") + () => SentryResponses.GetRateLimitResponse("1234:error, 897:transaction") )); using var backpressureMonitor = new BackpressureMonitor(null, _fakeClock, false); diff --git a/test/Sentry.Tests/Internals/Http/RateLimitCategoryTests.cs b/test/Sentry.Tests/Internals/Http/RateLimitCategoryTests.cs index f2d4001b39..ea8d580aee 100644 --- a/test/Sentry.Tests/Internals/Http/RateLimitCategoryTests.cs +++ b/test/Sentry.Tests/Internals/Http/RateLimitCategoryTests.cs @@ -5,11 +5,17 @@ namespace Sentry.Tests.Internals.Http; public class RateLimitCategoryTests { [Theory] - [InlineData("event", EnvelopeItem.TypeValueEvent)] + // Rate limits are keyed by data category, which is not always the envelope item type + [InlineData("error", EnvelopeItem.TypeValueEvent)] + [InlineData("monitor", EnvelopeItem.TypeValueCheckIn)] + [InlineData("log_item", EnvelopeItem.TypeValueLog)] + [InlineData("trace_metric", EnvelopeItem.TypeValueTraceMetric)] [InlineData("metric_bucket", EnvelopeItem.TypeValueMetric)] + [InlineData("feedback", EnvelopeItem.TypeValueFeedback)] [InlineData("session", EnvelopeItem.TypeValueSession)] [InlineData("transaction", EnvelopeItem.TypeValueTransaction)] [InlineData("attachment", EnvelopeItem.TypeValueAttachment)] + [InlineData("profile", EnvelopeItem.TypeValueProfile)] [InlineData("", EnvelopeItem.TypeValueEvent)] [InlineData("", EnvelopeItem.TypeValueMetric)] [InlineData("", EnvelopeItem.TypeValueSession)] @@ -31,7 +37,12 @@ public void Matches_IncludedItemType_ShouldMatch(string categoryName, string ite } [Theory] - [InlineData("event", EnvelopeItem.TypeValueTransaction)] + // The rate limit category is the data category, not the envelope item type: an "event" item is + // limited by the "error" category, so the literal item types below must not match. + [InlineData("event", EnvelopeItem.TypeValueEvent)] + [InlineData("check_in", EnvelopeItem.TypeValueCheckIn)] + [InlineData("statsd", EnvelopeItem.TypeValueMetric)] + [InlineData("error", EnvelopeItem.TypeValueTransaction)] [InlineData("error", EnvelopeItem.TypeValueAttachment)] [InlineData("session", EnvelopeItem.TypeValueEvent)] [InlineData("metric_bucket", EnvelopeItem.TypeValueSession)] diff --git a/test/Sentry.Tests/ModuleInit.cs b/test/Sentry.Tests/ModuleInit.cs index 996e8f7dd4..57513fea72 100644 --- a/test/Sentry.Tests/ModuleInit.cs +++ b/test/Sentry.Tests/ModuleInit.cs @@ -2,7 +2,6 @@ public static class ModuleInit { [ModuleInitializer] - [SuppressMessage("Usage", "CA2255:The \'ModuleInitializer\' attribute should not be used in libraries")] public static void Init() { CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("en-US"); diff --git a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs index 1835016cf9..732820851e 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs @@ -753,6 +753,34 @@ public async Task Roundtrip_WithEvent_WithAttachment_Success() envelopeRoundtrip.Items[1].TryGetOrRecalculateLength().Should().Be(attachment.Content.GetStream().Length); } + [Fact] + public void FromTransaction_WithNullAttachment_SkipsItAndLogsWarning() + { + // Arrange + var tracer = new TransactionTracer(DisabledHub.Instance, "name", "op"); + var transaction = new SentryTransaction(tracer); + + using var attachmentStream = new MemoryStream(new byte[] { 1, 2, 3 }); + var validAttachment = new SentryAttachment( + AttachmentType.Default, + new StreamAttachmentContent(attachmentStream), + "file.txt", + null); + + var attachments = new List { null!, validAttachment }; + var logger = new InMemoryDiagnosticLogger(); + + // Act + using var envelope = Envelope.FromTransaction(transaction, logger, attachments); + + // Assert + // Only the transaction item and the single valid attachment - the null is skipped. + envelope.Items.Count(item => item.TryGetType() == "attachment").Should().Be(1); + logger.Entries.Should().ContainSingle(e => + e.Level == SentryLevel.Warning && + e.Message == "Encountered a null attachment. Skipping."); + } + [Fact] public async Task Roundtrip_WithEvent_WithSession_Success() { diff --git a/test/Sentry.Tests/ScopeTests.cs b/test/Sentry.Tests/ScopeTests.cs index ccb1b277bf..0e04ec4b34 100644 --- a/test/Sentry.Tests/ScopeTests.cs +++ b/test/Sentry.Tests/ScopeTests.cs @@ -113,6 +113,21 @@ public void Clone_CopiesFields() Assert.Equal(_sut.Environment, clone.Environment); } + [Fact] + public void Clone_EnvironmentOverridesOptions_OverridePreserved() + { + // Arrange + var options = new SentryOptions { Environment = "production" }; + var scope = new Scope(options); + scope.Environment = "staging"; + + // Act + var clone = scope.Clone(); + + // Assert + clone.Environment.Should().Be("staging"); + } + [Fact] public void TransactionName_TransactionNotStarted_NameIsSet() { @@ -696,6 +711,84 @@ public void AddBreadcrumb_ObserverExist_ObserverAddsBreadcrumbIfEnabled(bool obs observer.Received(expectedCount).AddBreadcrumb(Arg.Is(breadcrumb)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SetEnvironment_ObserverExist_ObserverSetsEnvironmentIfEnabled(bool observerEnable) + { + // Arrange + var observer = Substitute.For(); + var scope = new Scope(new SentryOptions + { + ScopeObserver = observer, + EnableScopeSync = observerEnable + }); + const string expectedEnvironment = "staging"; + var expectedCount = observerEnable ? 1 : 0; + + // Act + scope.Environment = expectedEnvironment; + + // Assert + observer.Received(expectedCount).SetEnvironment(Arg.Is(expectedEnvironment)); + } + + [Fact] + public void SetEnvironment_Null_EnvironmentSetToOptionEnvironment() + { + // Arrange + const string optionsEnvironment = "production"; + var scope = new Scope(new SentryOptions { Environment = optionsEnvironment }); + scope.Environment = "staging"; // Override before resetting + + // Act + scope.Environment = null; + + // Assert + scope.Environment.Should().Be(optionsEnvironment); + } + + [Fact] + public void SetEnvironment_Null_ObserverReceivesOptionEnvironment() + { + // Arrange + const string optionsEnvironment = "production"; + var observer = Substitute.For(); + var scope = new Scope(new SentryOptions + { + ScopeObserver = observer, + EnableScopeSync = true, + Environment = optionsEnvironment + }); + scope.Environment = "staging"; // Override before resetting + observer.ClearReceivedCalls(); + + // Act + scope.Environment = null; + + // Assert + observer.Received(1).SetEnvironment(Arg.Is(optionsEnvironment)); + } + + [Fact] + public void SetEnvironment_SameValue_ObserverNotifiedOnce() + { + // Arrange + var observer = Substitute.For(); + var scope = new Scope(new SentryOptions + { + ScopeObserver = observer, + EnableScopeSync = true + }); + + // Act + scope.Environment = "staging"; + scope.Environment = "staging"; + + // Assert + observer.Received(1).SetEnvironment(Arg.Is("staging")); + } + [Fact] public void Filtered_tags_are_not_set() { diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index ab57b04227..b8121a4ff2 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1523,6 +1523,75 @@ public void CaptureTransaction_ScopeContainsAttachments_GetAppliedToHint() hint.Attachments.Should().Contain(attachments); } + [Fact] + public void CaptureTransaction_AttachmentWithAddToTransactionsTrue_IncludedInEnvelope() + { + // Arrange + var transaction = new SentryTransaction("name", "operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + var attachment = AttachmentHelper.FakeAttachment("include.txt", addToTransactions: true); + var scope = new Scope(_fixture.SentryOptions); + scope.AddAttachment(attachment); + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(transaction, scope, null); + + // Assert + sut.Worker.Received(1).EnqueueEnvelope(Arg.Is(envelope => + envelope.Items.Count(item => item.TryGetType() == "attachment") == 1)); + } + + [Fact] + public void CaptureTransaction_AttachmentWithAddToTransactionsFalse_ExcludedFromEnvelope() + { + // Arrange + var transaction = new SentryTransaction("name", "operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + var scope = new Scope(_fixture.SentryOptions); + scope.AddAttachment(AttachmentHelper.FakeAttachment("exclude.txt")); // default: AddToTransactions = false + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(transaction, scope, null); + + // Assert + sut.Worker.Received(1).EnqueueEnvelope(Arg.Is(envelope => + envelope.Items.Count(item => item.TryGetType() == "attachment") == 0)); + } + + [Fact] + public void CaptureTransaction_NullAttachmentInHint_DoesNotThrowAndSkipsNull() + { + // Arrange + var transaction = new SentryTransaction("name", "operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + var scope = new Scope(_fixture.SentryOptions); + var sut = _fixture.GetSut(); + + // A null entry in the hint's attachments must not crash transaction capture. + var hint = new SentryHint(); + hint.Attachments.Add(null!); + hint.Attachments.Add(AttachmentHelper.FakeAttachment("include.txt", addToTransactions: true)); + + // Act + var capture = () => sut.CaptureTransaction(transaction, scope, hint); + + // Assert + capture.Should().NotThrow(); + sut.Worker.Received(1).EnqueueEnvelope(Arg.Is(envelope => + envelope.Items.Count(item => item.TryGetType() == "attachment") == 1)); + } + [SkippableFact] public void CaptureTransaction_UserIsNull_SetsFallbackUserId() { diff --git a/warden.toml b/warden.toml index 01a67776d0..672548d0c6 100644 --- a/warden.toml +++ b/warden.toml @@ -13,7 +13,6 @@ version = 1 # Default settings inherited by all skills [defaults] runtime = "pi" -model = "anthropic/claude-opus-4-5" # Severity levels: critical, high, medium, low, info # failOn: minimum severity that fails the check failOn = "high"