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** | [](https://www.nuget.org/packages/Sentry.Maui) | [](https://www.nuget.org/packages/Sentry.Maui) | [](https://www.nuget.org/packages/Sentry.Maui) | [](https://docs.sentry.io/platforms/dotnet/guides/maui) |
| **Sentry.NLog** | [](https://www.nuget.org/packages/Sentry.NLog) | [](https://www.nuget.org/packages/Sentry.NLog) | [](https://www.nuget.org/packages/Sentry.NLog) | [](https://docs.sentry.io/platforms/dotnet/guides/nlog) |
| **Sentry.OpenTelemetry** | [](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [](https://docs.sentry.io/platforms/dotnet/performance/instrumentation/opentelemetry/) |
+| **Sentry.OpenTelemetry.Exporter** | [](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry-otlp/) |
| **Sentry.Profiling** | [](https://www.nuget.org/packages/Sentry.Profiling) | [](https://www.nuget.org/packages/Sentry.Profiling) | [](https://www.nuget.org/packages/Sentry.Profiling) | [](https://docs.sentry.io/platforms/dotnet/profiling/) |
| **Sentry.Serilog** | [](https://www.nuget.org/packages/Serilog) | [](https://www.nuget.org/packages/Sentry.Serilog) | [](https://www.nuget.org/packages/Sentry.Serilog) | [](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